Skip to content

Vertical Timeline Reveal

A scroll-triggered vertical timeline whose dots and cards reveal in sequence.

Freeeditorialminimal
Category
Sections
Added
2026-08-23
Deps
1
Updated
2026-09-01
Preview

Idea

Sketch the vibe, pick a direction.

Build

Ship real components, not mockups.

Launch

Ready for production from day one.

Props

Requires framer-motion
"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>
  );
}
Notes

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.

Related

Pairs well with

Matched on shared tags and category — the entries most likely to be used alongside this one.

An image whose circular clip-path mask grows and shrinks in lockstep with scroll.

scrollimagemask
Pro

A wrapper that reveals its children one after another as the group scrolls into view.

staggerrevealscroll
Featured
NewPro

Copy that resolves word by word as you scroll, mapped by real reading position rather than word index.

scrolltypereveal