"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>
);
}