Vertical Timeline Reveal
A scroll-triggered vertical timeline whose dots and cards reveal in sequence.
DOM + CSS1 dep1 file · 2 KB TSX
Props
"use client";
import { useRef } from "react";
import { motion, useInView } from "framer-motion";
type Step = { title: string; body: string };
const DEFAULT_STEPS: Step[] = [
{ title: "Idea", body: "Sketch the vibe, pick a direction." },
{ title: "Build", body: "Ship real components, not mockups." },
{ title: "Launch", body: "Ready for production from day one." },
];
function TimelineItem({ step, index }: { step: Step; index: number }) {
const ref = useRef<HTMLDivElement>(null);
const isInView = useInView(ref, { once: true, amount: 0.6 });
return (
<div ref={ref} className="relative flex gap-4 pb-8 last:pb-0">
<div className="relative flex w-6 shrink-0 flex-col items-center">
<motion.span
initial={{ scale: 0 }}
animate={isInView ? { scale: 1 } : {}}
transition={{ duration: 0.3, delay: index * 0.15, ease: [0.65, 0, 0.35, 1] }}
className="z-10 mt-1 h-3 w-3 shrink-0 rounded-full bg-red-500"
/>
<span className="absolute top-4 bottom-0 w-px bg-neutral-200" />
</div>
<motion.div
initial={{ opacity: 0, x: -12 }}
animate={isInView ? { opacity: 1, x: 0 } : {}}
transition={{ duration: 0.4, delay: index * 0.15 + 0.1, ease: [0.16, 1, 0.3, 1] }}
className="rounded-xl border border-neutral-200 px-4 py-3"
>
<p className="text-sm font-bold">{step.title}</p>
<p className="mt-0.5 text-xs text-neutral-500">{step.body}</p>
</motion.div>
</div>
);
}
export function VerticalTimelineReveal({ steps = DEFAULT_STEPS }: { steps?: Step[] }) {
return (
<div className="w-full max-w-sm">
{steps.map((step, i) => (
<TimelineItem key={step.title + i} step={step} index={i} />
))}
</div>
);
}
Free. Pro unlocks everything, from $27/month.
Details
About this section
A timeline where every dot and card fades in together the instant the first one enters view doesn't read as a timeline at all — it reads as one block that happened to animate. Each step here is its own subcomponent with its own ref and its own in-view check, so a dot only animates when its own position crosses into the viewport, not when the list as a whole first appears. Layered on top of that per-item trigger is an index-based stagger delay, so items that happen to enter the viewport together still reveal in visible top-to-bottom order — but the stagger clock resets per intersection, so an item far down the page doesn't inherit any timing relationship to items already revealed long before it. Reach for this over a single shared in-view trigger on the whole list whenever the steps span more vertical space than one viewport. The connecting line is drawn per item rather than as one continuous element behind the whole list, which keeps each row self-contained but means there's no single line to restyle globally.
Curator’s note
Each timeline item runs its own useInView instead of one shared observer for the whole list — a single top-level trigger would fire every item at once the moment the FIRST one entered view, losing the sequential top-to-bottom reveal that makes a timeline read as a timeline.

