Scroll Reveal Timeline
Original · freeA vertical timeline whose connecting line fills and whose milestones fade in as they enter the viewport.
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/scroll-reveal-timeline.json"use client";
import { useRef, type ReactNode } from "react";
import {
motion,
useScroll,
useSpring,
useTransform,
type Variants,
} from "motion/react";
type Milestone = {
date: string;
tag: string;
title: string;
body: string;
icon: ReactNode;
};
const MILESTONES: Milestone[] = [
{
date: "Q1 2021",
tag: "Origin",
title: "Two laptops and a shared doc",
body: "We shipped the first prototype from a rented co-working desk, onboarding three pilot teams who believed in the idea before there was a landing page.",
icon: (
<path d="M12 3v6m0 0 3-3m-3 3L9 6m-3 9h12l-1.5 6h-9L6 15Z" />
),
},
{
date: "Q4 2021",
tag: "Traction",
title: "First 1,000 paying accounts",
body: "Word of mouth outpaced our ad spend. We rewrote the sync engine over one long weekend and cut median load time from 2.4s to 380ms.",
icon: (
<path d="M3 17l6-6 4 4 8-8m0 0h-5m5 0v5" />
),
},
{
date: "Q3 2022",
tag: "Scale",
title: "Global infrastructure, one region at a time",
body: "Edge nodes across four continents brought p99 latency under 90ms for every customer. Uptime crossed 99.98% and stayed there.",
icon: (
<path d="M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 0c2.5 2.4 3.75 5.4 3.75 9S14.5 18.6 12 21m0-18C9.5 5.4 8.25 8.4 8.25 12S9.5 18.6 12 21M3.5 9h17M3.5 15h17" />
),
},
{
date: "Q2 2023",
tag: "Team",
title: "Fifty people, still no meetings on Fridays",
body: "We grew the team fivefold without diluting the craft. A written-first culture meant every new engineer shipped to production in their first week.",
icon: (
<path d="M16 19a4 4 0 0 0-8 0m10-4a3 3 0 0 0-3-3m-6 0a3 3 0 0 0-3 3m9-8a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0Zm5 1a2 2 0 1 1-4 0 2 2 0 0 1 4 0Zm-14 0a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z" />
),
},
{
date: "Q1 2024",
tag: "Today",
title: "A platform, not just a product",
body: "An open API and a marketplace of 200+ integrations turned early adopters into builders. The roadmap is now written as much by them as by us.",
icon: (
<path d="M12 2 4 6v6c0 5 3.5 8 8 10 4.5-2 8-5 8-10V6l-8-4Zm-2.5 9.5 2 2 4-4.5" />
),
},
];
const cardVariants: Variants = {
hidden: { opacity: 0, y: 48 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.65, ease: [0.22, 1, 0.36, 1] },
},
};
const nodeVariants: Variants = {
hidden: { scale: 0, opacity: 0 },
visible: {
scale: 1,
opacity: 1,
transition: { duration: 0.5, ease: [0.34, 1.56, 0.64, 1], delay: 0.1 },
},
};
export default function ScrollRevealTimeline() {
const trackRef = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
target: trackRef,
offset: ["start 65%", "end 65%"],
});
const fill = useSpring(scrollYProgress, {
stiffness: 120,
damping: 28,
restDelta: 0.001,
});
const glowOpacity = useTransform(fill, [0, 0.05, 0.95, 1], [0, 1, 1, 0.6]);
return (
<section className="relative w-full overflow-hidden bg-slate-50 px-5 py-24 text-slate-900 sm:px-8 dark:bg-slate-950 dark:text-slate-100">
<style>{`
@keyframes szh-pulse {
0%, 100% { transform: scale(1); opacity: 0.55; }
50% { transform: scale(1.9); opacity: 0; }
}
@keyframes szh-drift {
0% { transform: translateY(0) translateX(0); }
50% { transform: translateY(-18px) translateX(10px); }
100% { transform: translateY(0) translateX(0); }
}
@media (prefers-reduced-motion: reduce) {
.szh-pulse, .szh-drift { animation: none !important; }
}
`}</style>
{/* Decorative ambient blobs */}
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
<div className="szh-drift absolute -left-24 top-24 h-72 w-72 rounded-full bg-gradient-to-br from-indigo-300/40 to-violet-300/30 blur-3xl dark:from-indigo-600/20 dark:to-violet-700/20" />
<div className="szh-drift absolute -right-20 bottom-32 h-80 w-80 rounded-full bg-gradient-to-br from-fuchsia-300/30 to-sky-300/30 blur-3xl dark:from-fuchsia-700/15 dark:to-sky-700/15" style={{ animationDelay: "1.5s" }} />
<div
className="absolute inset-0 opacity-[0.5] dark:opacity-[0.35]"
style={{
backgroundImage:
"radial-gradient(circle at 1px 1px, rgba(99,102,241,0.15) 1px, transparent 0)",
backgroundSize: "38px 38px",
maskImage:
"radial-gradient(ellipse 70% 60% at 50% 40%, black, transparent)",
WebkitMaskImage:
"radial-gradient(ellipse 70% 60% at 50% 40%, black, transparent)",
}}
/>
</div>
<div className="relative mx-auto max-w-3xl">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.6 }}
transition={{ duration: 0.7, ease: [0.22, 1, 0.36, 1] }}
className="mx-auto mb-20 max-w-xl text-center"
>
<span className="inline-flex items-center gap-2 rounded-full border border-indigo-200 bg-white/70 px-4 py-1.5 text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 backdrop-blur dark:border-indigo-500/30 dark:bg-white/5 dark:text-indigo-300">
<span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
Our journey
</span>
<h2 className="mt-5 bg-gradient-to-br from-slate-900 via-slate-800 to-slate-600 bg-clip-text text-4xl font-bold tracking-tight text-transparent sm:text-5xl dark:from-white dark:via-slate-100 dark:to-slate-400">
From a shared doc to a platform
</h2>
<p className="mt-4 text-base leading-relaxed text-slate-600 dark:text-slate-400">
Scroll to trace the milestones that shaped us. The line fills as you
go; each moment arrives in its own time.
</p>
</motion.div>
{/* Timeline track */}
<div ref={trackRef} className="relative">
{/* Rail — offset left on mobile, centered on desktop */}
<div
aria-hidden="true"
className="absolute bottom-0 top-0 left-[19px] w-px sm:left-1/2 sm:-translate-x-1/2"
>
{/* Base rail */}
<div className="absolute inset-0 w-px bg-slate-200 dark:bg-slate-800" />
{/* Animated fill */}
<motion.div
style={{ scaleY: fill, transformOrigin: "top" }}
className="absolute inset-0 w-px bg-gradient-to-b from-indigo-500 via-violet-500 to-fuchsia-500"
/>
{/* Traveling glow at the fill head */}
<motion.div
style={{ scaleY: fill, transformOrigin: "top", opacity: glowOpacity }}
className="absolute inset-0 w-px bg-gradient-to-b from-transparent via-transparent to-fuchsia-400 blur-[3px]"
/>
</div>
<ol className="relative space-y-14 sm:space-y-24">
{MILESTONES.map((m, i) => {
const alignRight = i % 2 === 1;
return (
<li key={m.date} className="relative">
<div
className={[
"relative flex items-start gap-6 sm:grid sm:grid-cols-[1fr_auto_1fr] sm:items-center sm:gap-0",
].join(" ")}
>
{/* Node */}
<motion.div
variants={nodeVariants}
initial="hidden"
whileInView="visible"
viewport={{ once: true, amount: 0.8 }}
className="relative z-10 order-first flex h-10 w-10 shrink-0 items-center justify-center sm:order-none sm:col-start-2"
>
<span
aria-hidden="true"
className="szh-pulse absolute inset-0 rounded-full bg-violet-500/50"
/>
<span className="relative flex h-10 w-10 items-center justify-center rounded-full border border-white/60 bg-gradient-to-br from-indigo-500 via-violet-500 to-fuchsia-500 text-white shadow-lg shadow-violet-500/30 ring-4 ring-slate-50 dark:border-white/20 dark:ring-slate-950">
<svg
viewBox="0 0 24 24"
className="h-5 w-5"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
{m.icon}
</svg>
</span>
</motion.div>
{/* Spacer for the empty column on desktop */}
<div
aria-hidden="true"
className={[
"hidden sm:block",
alignRight ? "sm:col-start-1" : "sm:col-start-3",
].join(" ")}
/>
{/* Card */}
<motion.div
variants={cardVariants}
initial="hidden"
whileInView="visible"
viewport={{ once: true, amount: 0.5 }}
className={[
"min-w-0 flex-1 sm:row-start-1",
alignRight
? "sm:col-start-3 sm:pl-10 sm:text-left"
: "sm:col-start-1 sm:pr-10 sm:text-right",
].join(" ")}
>
<article
className={[
"group relative overflow-hidden rounded-2xl border border-slate-200 bg-white/80 p-6 shadow-sm backdrop-blur-sm transition-shadow duration-300 hover:shadow-xl hover:shadow-violet-500/10 dark:border-white/10 dark:bg-white/[0.04]",
].join(" ")}
>
{/* accent edge */}
<span
aria-hidden="true"
className={[
"absolute inset-y-0 w-1 bg-gradient-to-b from-indigo-500 via-violet-500 to-fuchsia-500",
alignRight ? "left-0" : "left-0 sm:left-auto sm:right-0",
].join(" ")}
/>
<div
className={[
"flex items-center gap-3",
alignRight ? "" : "sm:flex-row-reverse",
].join(" ")}
>
<time className="text-sm font-bold tracking-tight text-violet-600 dark:text-violet-300">
{m.date}
</time>
<span className="rounded-full bg-indigo-50 px-2.5 py-0.5 text-[11px] font-semibold uppercase tracking-wider text-indigo-600 dark:bg-indigo-500/15 dark:text-indigo-300">
{m.tag}
</span>
</div>
<h3 className="mt-3 text-lg font-semibold leading-snug text-slate-900 dark:text-white">
{m.title}
</h3>
<p className="mt-2 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
{m.body}
</p>
</article>
</motion.div>
</div>
</li>
);
})}
</ol>
</div>
{/* Closing line */}
<motion.p
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.8 }}
transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
className="mt-20 text-center text-sm font-medium text-slate-500 dark:text-slate-500"
>
And the next milestone is still being written.
</motion.p>
</div>
</section>
);
}Dependencies
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Scroll Progress Reveal
OriginalA section that reveals its content in sequence as you scroll, with a sticky scroll-progress bar showing how far through it you are.

Parallax Layers
OriginalA layered depth scene where background, midground and foreground drift at different speeds, driven by scroll position.

Pinned Scroll Steps
OriginalA pinned, sticky panel whose visual and copy change step by step as you scroll past it, a classic scrollytelling section.

Horizontal Scroll Gallery
OriginalVertical scrolling pans a horizontal rail of cards sideways while the section is pinned, then releases.

Count-up Stats Band
OriginalA metrics band whose numbers count up from zero the moment it scrolls into view.

Clip-path Image Reveal
OriginalPanels that unmask with a clip-path wipe as they enter the viewport, revealing the media beneath.

Stacking Cards
OriginalA deck of cards that pin and scale as you scroll, each settling behind the next.

Scroll Text Highlight
OriginalA statement whose words light up one by one as you scroll through the section.

Scroll Zoom Hero
OriginalA hero whose backdrop scales and brightens while the headline parallaxes upward as you scroll.

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

Split Hero
OriginalA two-column hero pairing a headline and CTAs with a product mock and social proof.

Gradient Spotlight Hero
OriginalA minimal centred hero with a soft gradient-mesh backdrop, announcement pill and dual call-to-action buttons.

