Feedback & Data
TimelineFlow
A rail fills as you scroll, and each event lights up the moment the fill reaches its dot. Dot places are measured, so rows of any height line up, and the last one always lights.
Download
- React
- Tailwind CSS v4
- motion
components/ui/timeline-flow.tsx
"use client";
import { useEffect, useRef, useState, type CSSProperties, type RefObject } from "react";
import { motion, useMotionValueEvent, useReducedMotion, useScroll, useSpring } from "motion/react";
type TimelineFlowProps = {
/** Events in order, oldest or first step on top. */
items: { title: string; time?: string; description?: string }[];
/** Scroll box to watch. Leave out to follow the page. */
container?: RefObject<HTMLElement | null>;
/** Rail and dot colour. */
accent?: string;
/** Wrapper look. */
className?: string;
};
const countLit = (marks: number[], progress: number) => marks.filter((mark) => mark <= progress).length;
/* TimelineFlow: a rail fills as you scroll, and each event lights up the moment the fill reaches its dot. */
export function TimelineFlow({ items, container, accent = "#a78bfa", className = "" }: TimelineFlowProps) {
const reduce = useReducedMotion();
const list = useRef<HTMLOListElement>(null);
const marks = useRef<number[]>([]);
const [lit, setLit] = useState(0);
const { scrollYProgress } = useScroll({ target: list, container, offset: ["start 0.9", "end end"] });
const fill = useSpring(scrollYProgress, { stiffness: 140, damping: 28 });
useMotionValueEvent(fill, "change", (progress) => setLit(countLit(marks.current, progress)));
// Each dot's place along the rail, kept fresh as text wraps on resize.
useEffect(() => {
const box = list.current;
if (!box) return;
const measure = () => {
marks.current = [...box.children].map((el) => (el as HTMLElement).offsetTop / box.offsetHeight);
setLit(countLit(marks.current, fill.get()));
};
const observer = new ResizeObserver(measure);
observer.observe(box);
return () => observer.disconnect();
}, [fill]);
return (
<ol ref={list} style={{ "--tf": accent } as CSSProperties} className={`relative ${className}`}>
<span aria-hidden className="absolute top-1 bottom-0 left-[7px] w-0.5 overflow-hidden bg-white/10">
<motion.span className="block size-full origin-top bg-(--tf)" style={{ scaleY: reduce ? 1 : fill }} />
</span>
{items.map((item, i) => {
const on = reduce || i < lit;
return (
<li key={item.title} className="relative pb-7 pl-8 last:pb-0">
<motion.span
aria-hidden
className={`absolute top-1 left-0 size-4 rounded-full border-2 transition-[background-color,border-color,box-shadow] duration-300 ${
on ? "border-(--tf) bg-(--tf) shadow-[0_0_12px_var(--tf)]" : "border-white/25 bg-[#0b0a14]"
}`}
animate={{ scale: on ? 1 : 0.7 }}
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 420, damping: 18 }}
/>
<motion.div animate={{ opacity: on ? 1 : 0.4, x: on ? 0 : 8 }} transition={reduce ? { duration: 0 } : { duration: 0.35, ease: "easeOut" }}>
{item.time && <p className="text-xs text-white/50">{item.time}</p>}
<h3 className="text-sm font-medium text-white">{item.title}</h3>
{item.description && <p className="mt-1 text-[13px] leading-relaxed text-white/60">{item.description}</p>}
</motion.div>
</li>
);
})}
</ol>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/timeline-flow.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/timeline-flow-demo.tsx
"use client"; import { useRef } from "react"; import { TimelineFlow } from "@/components/ui/timeline-flow"; const STEPS = [ { time: "Week 1", title: "Idea written down", description: "One page on who it is for and why they would pay." }, { time: "Week 2", title: "First sketches", description: "Three rough screens, shown to five people." }, { time: "Week 4", title: "Private beta", description: "Twenty users in, a list of what broke." }, { time: "Week 6", title: "Payments on", description: "First real sale, and a long look at the refund flow." }, { time: "Week 8", title: "Public launch", description: "Doors open. The waitlist gets in first." }, { time: "Week 10", title: "Second release", description: "The top three asks from launch week, shipped." }, ]; export default function TimelineFlowExample() { // The preview scrolls inside its own box. On a real page, leave out `container`. const box = useRef<HTMLDivElement>(null); return ( <div ref={box} className="relative h-full overflow-y-auto bg-[#0b0a14] p-5"> <TimelineFlow items={STEPS} container={box} className="mx-auto max-w-md" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| items | { title, time?, description? }[] | โ | Events in order. Titles must be different. |
| container | RefObject<HTMLElement | null> | โ | Scroll box to watch. It needs position: relative. Leave out to follow the page. |
| accent | string | "#a78bfa" | Rail and dot colour. |
| className | string | โ | Wrapper look. |