Fazekit

Code

"use client";
import * as React from "react";
import { motion, useInView, useReducedMotion, useScroll, useSpring, useTransform } from "motion/react";
import { Check, CircleDashed, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";

export type MilestoneStatus = "shipped" | "in-progress" | "planned";

export interface Milestone {
  date: string;
  title: string;
  description: string;
  status?: MilestoneStatus;
  tags?: string[];
}

export interface TimelineScrollProps {
  eyebrow?: string;
  title?: string;
  description?: string;
  milestones?: Milestone[];
  className?: string;
}

const MILESTONES: Milestone[] = [
  { date: "Q1 2025", title: "Private beta", description: "Opened the doors to 200 design partners and rebuilt onboarding three times from their feedback.", status: "shipped", tags: ["Onboarding", "Beta"] },
  { date: "Q3 2025", title: "Public launch", description: "Self-serve sign-up, usage-based billing and the first public API. 12k teams joined in the first month.", status: "shipped", tags: ["API", "Billing"] },
  { date: "Q1 2026", title: "Realtime collaboration", description: "Multiplayer editing with presence, comments and version history that syncs in under 50 ms.", status: "shipped", tags: ["Multiplayer"] },
  { date: "Q3 2026", title: "AI copilots", description: "Assistants that draft, summarise and automate across your workspace — grounded in your own data.", status: "in-progress", tags: ["AI", "Automation"] },
  { date: "Q1 2027", title: "Marketplace", description: "A curated store for extensions and templates, with revenue share for the builders who make them.", status: "planned", tags: ["Ecosystem"] },
  { date: "Q3 2027", title: "On-prem & sovereign cloud", description: "Deploy into your own VPC or a regional sovereign cloud with the exact same product experience.", status: "planned", tags: ["Enterprise", "Security"] },
];

const STATUS: Record<MilestoneStatus, { label: string; className: string; icon: React.ReactNode }> = {
  shipped: {
    label: "Shipped",
    className: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
    icon: <Check className="size-3" strokeWidth={3} aria-hidden />,
  },
  "in-progress": {
    label: "In progress",
    className: "bg-primary/10 text-primary",
    icon: <Loader2 className="size-3 animate-spin" aria-hidden />,
  },
  planned: {
    label: "Planned",
    className: "bg-muted text-muted-foreground",
    icon: <CircleDashed className="size-3" aria-hidden />,
  },
};

export function TimelineScroll({
  eyebrow = "Roadmap",
  title = "Where we've been, where we're going",
  description = "A public roadmap, updated every quarter. Scroll to travel through time.",
  milestones = MILESTONES,
  className,
}: TimelineScrollProps) {
  const listRef = React.useRef<HTMLOListElement>(null);
  const reduce = useReducedMotion();
  const { scrollYProgress } = useScroll({ target: listRef, offset: ["start 65%", "end 65%"] });
  const progress = useSpring(scrollYProgress, { stiffness: 120, damping: 28, mass: 0.4 });
  const headTop = useTransform(progress, (v) => `${Math.min(1, Math.max(0, v)) * 100}%`);
  const headOpacity = useTransform(progress, [0, 0.02, 0.98, 1], [0, 1, 1, 0.6]);

  return (
    <section className={cn("relative w-full overflow-x-clip bg-background py-16 sm:py-24", className)}>
      <div className="mx-auto max-w-5xl px-4 sm:px-6">
        <motion.div
          initial={{ opacity: 0, y: reduce ? 0 : 14 }}
          whileInView={{ opacity: 1, y: 0 }}
          viewport={{ once: true, amount: 0.5 }}
          transition={{ duration: 0.6 }}
          className="mx-auto max-w-2xl text-center"
        >
          <p className="text-sm font-medium text-primary">{eyebrow}</p>
          <h2 className="mt-2 text-balance text-3xl font-semibold tracking-tight text-foreground sm:text-4xl lg:text-5xl">{title}</h2>
          <p className="mt-4 text-pretty text-muted-foreground">{description}</p>
        </motion.div>

        <div className="relative mt-14 sm:mt-20">
          {/* rail */}
          <div aria-hidden className="absolute bottom-0 left-5 top-0 w-px -translate-x-1/2 bg-border md:left-1/2">
            <motion.div
              style={{ scaleY: progress }}
              className="absolute inset-0 origin-top bg-gradient-to-b from-primary via-fuchsia-500 to-primary"
            />
            <motion.span
              style={{ top: headTop, opacity: headOpacity }}
              className="absolute left-1/2 size-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary shadow-[0_0_0_6px] shadow-primary/20"
            >
              <span className="absolute inset-0 animate-ping rounded-full bg-primary/60" />
            </motion.span>
          </div>

          <ol ref={listRef} className="relative space-y-10 md:space-y-0">
            {milestones.map((m, i) => (
              <MilestoneRow key={`${m.date}-${m.title}`} milestone={m} side={i % 2 === 0 ? "left" : "right"} first={i === 0} />
            ))}
          </ol>
        </div>
      </div>
    </section>
  );
}

function MilestoneRow({ milestone: m, side, first }: { milestone: Milestone; side: "left" | "right"; first: boolean }) {
  const ref = React.useRef<HTMLSpanElement>(null);
  const reduce = useReducedMotion();
  // "Reached" once the node passes 65% of the viewport (where the progress head is) — stays true while it's above.
  const reached = useInView(ref, { margin: "100000px 0px -35% 0px" });
  const status = STATUS[m.status ?? "planned"];
  const fromX = reduce ? 0 : side === "left" ? -40 : 40;

  return (
    <li className={cn("relative grid pl-12 md:grid-cols-2 md:gap-16 md:pl-0", !first && "md:-mt-14")}>
      {/* node */}
      <span
        ref={ref}
        aria-hidden
        className={cn(
          "absolute left-5 top-6 z-10 flex size-4 -translate-x-1/2 items-center justify-center rounded-full border-2 transition-all duration-500 md:left-1/2",
          reached ? "border-primary bg-background shadow-[0_0_0_5px] shadow-primary/15" : "border-border bg-background",
        )}
      >
        <span className={cn("size-1.5 rounded-full transition-colors duration-500", reached ? "bg-primary" : "bg-transparent")} />
      </span>

      <motion.article
        initial={{ opacity: 0, x: fromX, y: reduce ? 0 : 12, filter: reduce ? "blur(0px)" : "blur(6px)" }}
        whileInView={{ opacity: 1, x: 0, y: 0, filter: "blur(0px)" }}
        viewport={{ once: true, amount: 0.35 }}
        transition={{ duration: 0.7, ease: [0.22, 1, 0.36, 1] }}
        className={cn(
          "group relative rounded-2xl border bg-card p-5 shadow-sm transition-shadow hover:shadow-lg sm:p-6",
          side === "left" ? "md:col-start-1" : "md:col-start-2",
        )}
      >
        <div className="flex flex-wrap items-center gap-2">
          <span className={cn("font-mono text-xs transition-colors duration-500", reached ? "text-foreground" : "text-muted-foreground")}>{m.date}</span>
          <span className={cn("inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium", status.className)}>
            {status.icon}
            {status.label}
          </span>
        </div>
        <h3 className="mt-3 text-lg font-semibold tracking-tight text-card-foreground">{m.title}</h3>
        <p className="mt-1.5 text-pretty text-sm leading-relaxed text-muted-foreground">{m.description}</p>
        {m.tags && m.tags.length > 0 && (
          <ul className="mt-4 flex flex-wrap gap-1.5">
            {m.tags.map((t) => (
              <li key={t} className="rounded-md border bg-background px-2 py-0.5 text-[11px] text-muted-foreground">
                {t}
              </li>
            ))}
          </ul>
        )}
      </motion.article>
    </li>
  );
}

More in Features

View all →