Installation
Run this in a project set up with shadcn/ui:
$ pnpm dlx shadcn@latest add https://uxelith.com/r/timeline.jsonIt adds these packages for you:
clsxtailwind-mergemotionThe source lands in your components folder as
timeline.tsx. It's yours: change anything.
Usage
import { Timeline } from "@/components/timeline"
export function OrderProgress() {
return (
<Timeline
progress="status"
items={[
{ date: "Oct 2", title: "Order placed", status: "done" },
{ date: "Oct 3", title: "Shipped", status: "done" },
{ date: "Today", title: "Out for delivery", status: "current" },
{ title: "Delivered", status: "upcoming" },
]}
/>
)
}Source
One file, 198 lines, nothing hidden. Read it before you install it.
"use client";
import { motion, useMotionValue, useMotionValueEvent, useReducedMotion, useScroll, useSpring, useTransform } from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface TimelineEvent {
title: React.ReactNode;
description?: React.ReactNode;
/** Shown beside or above the title, e.g. "March 2026" or "Step 2". */
date?: React.ReactNode;
/** `done` gets a check, `current` a soft pulse, `upcoming` stays hollow. Spoken to screen readers. */
status?: "done" | "current" | "upcoming";
}
export interface TimelineProps extends React.ComponentProps<"ol"> {
items: TimelineEvent[];
/**
* `scroll`: a beam of light draws down the line as you scroll, lighting each event as it passes.
* `status`: the line is lit up to the current event (an order's progress).
*/
progress?: "scroll" | "status";
/** `split` puts dates in their own column that sticks while its event scrolls past (from the `sm` breakpoint). */
layout?: "stacked" | "split";
/** The element that scrolls, when it isn't the page (a scrollable card or panel). */
scrollContainer?: React.RefObject<HTMLElement | null>;
/** Ease events in as they scroll into view. */
animate?: boolean;
}
// Motion presets (ADR-010).
const FOLLOW_SMOOTH = { mass: 0.5, stiffness: 220, damping: 26 };
const SPRING_BOUNCY = { type: "spring", visualDuration: 0.45, bounce: 0.35 } as const;
const EASE_OUT = [0.22, 1, 0.36, 1] as const;
const statusText = { done: "Completed", current: "In progress", upcoming: "Upcoming" } as const;
/**
* A vertical sequence of events — a changelog, a roadmap, an order's progress.
*
* - An ordered list; each event's status is spoken ("Completed", "In
* progress"), not only drawn.
* - In `scroll` mode a beam follows your scroll down the line and each
* marker lights up as the beam reaches it; in `status` mode the line is lit
* up to the current event.
* - `layout="split"` gives dates a column of their own that sticks while
* the event beside it scrolls by.
* - Under `prefers-reduced-motion` the beam tracks the scroll exactly,
* without easing, and events are simply shown.
* - Colours: `--timeline-accent` (default `--primary`).
*/
export function Timeline({ items, progress = "scroll", layout = "stacked", scrollContainer, animate = true, className, ...props }: TimelineProps) {
const ref = React.useRef<HTMLOListElement>(null);
const reduceMotion = useReducedMotion();
// `initial` must match on the server, which can't know about reduced
// motion: reduced motion only makes the reveal instant.
const instant = reduceMotion ? { duration: 0 } : undefined;
// Where the markers sit, so the rail runs from the first to the last and
// we know when the beam reaches each one.
const [rail, setRail] = React.useState<{ top: number; height: number; stops: number[] } | null>(null);
// The event the line is lit up to in status mode: the current one, else the last done.
const currentIndex = items.findIndex((item) => item.status === "current");
const firstUpcoming = items.findIndex((item) => item.status === "upcoming");
const statusIndex = currentIndex !== -1 ? currentIndex : firstUpcoming !== -1 ? Math.max(firstUpcoming - 1, 0) : items.length - 1;
const { scrollYProgress } = useScroll({ target: ref, container: scrollContainer, offset: ["start 70%", "end 55%"] });
const eased = useSpring(scrollYProgress, FOLLOW_SMOOTH);
const still = useMotionValue(0);
const scrollMode = progress === "scroll" && animate;
const fill = scrollMode ? (reduceMotion ? scrollYProgress : eased) : still;
React.useEffect(() => {
if (!scrollMode) still.set(rail ? (rail.stops[statusIndex] ?? 1) : 0);
}, [scrollMode, rail, statusIndex, still]);
const [reached, setReached] = React.useState(scrollMode ? 0 : statusIndex + 1);
const update = React.useCallback(
(value: number) => {
if (!rail) return;
const count = rail.stops.filter((stop) => stop <= value + 0.002).length;
setReached((current) => (current === count ? current : count));
},
[rail],
);
useMotionValueEvent(fill, "change", update);
React.useLayoutEffect(() => {
const list = ref.current;
if (!list) return;
const measure = () => {
const markers = [...list.querySelectorAll<HTMLElement>("[data-marker]")];
if (markers.length === 0) return;
const base = list.getBoundingClientRect().top;
const centres = markers.map((m) => {
const r = m.getBoundingClientRect();
return r.top + r.height / 2 - base;
});
const top = centres[0]!;
const height = Math.max(centres[centres.length - 1]! - top, 1);
const stops = centres.map((c) => (c - top) / height);
setRail({ top, height, stops });
setReached(stops.filter((stop) => stop <= fill.get() + 0.002).length);
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(list);
return () => observer.disconnect();
}, [items.length, layout, fill]);
const headY = useTransform(fill, (value) => value * (rail?.height ?? 0));
const split = layout === "split";
return (
<ol ref={ref} className={cn("relative grid w-full [--timeline-accent-current:var(--timeline-accent,var(--primary))]", className)} {...props}>
{/* The rail, and the beam that fills it. */}
{rail && (
<div
aria-hidden="true"
className={cn("pointer-events-none absolute w-px", split ? "left-[calc(0.75rem-0.5px)] sm:left-[calc(9.75rem-0.5px)]" : "left-[calc(0.75rem-0.5px)]")}
style={{ top: rail.top, height: rail.height }}
>
<div className="absolute inset-0 bg-border" />
<motion.div
className="absolute inset-0 origin-top bg-gradient-to-b from-transparent via-[var(--timeline-accent-current)] to-[var(--timeline-accent-current)]"
style={{ scaleY: fill }}
/>
{scrollMode && (
<motion.div
className="absolute left-1/2 top-0 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-[var(--timeline-accent-current)] shadow-[0_0_12px_3px_color-mix(in_oklab,var(--timeline-accent-current)_60%,transparent)]"
style={{ y: headY }}
/>
)}
</div>
)}
{items.map((item, index) => {
const status = item.status;
const lit = index < reached && status !== "upcoming";
const last = index === items.length - 1;
return (
<motion.li
key={index}
initial={animate ? { opacity: 0, y: 12 } : false}
whileInView={animate ? { opacity: 1, y: 0 } : undefined}
viewport={{ root: scrollContainer, once: true, amount: 0.4 }}
transition={instant ?? { duration: 0.45, ease: EASE_OUT }}
className={cn("relative grid gap-x-4", split ? "grid-cols-[1.5rem_1fr] sm:grid-cols-[8rem_1.5rem_1fr]" : "grid-cols-[1.5rem_1fr]")}
>
{split && item.date && (
<div className="hidden sm:block">
<p className="sticky top-[var(--timeline-sticky-top,1.5rem)] pt-0.5 text-right text-sm font-medium text-muted-foreground tabular-nums">{item.date}</p>
</div>
)}
{split && !item.date && <div className="hidden sm:block" />}
<div className="relative flex justify-center">
<motion.span
aria-hidden="true"
data-marker=""
initial={false}
animate={{ scale: lit ? [1, 1.25, 1] : 1 }}
transition={instant ?? { duration: 0.4, ease: EASE_OUT }}
className={cn(
"relative mt-0.5 grid size-6 place-items-center rounded-full border-2 bg-background transition-colors duration-300 motion-reduce:transition-none",
lit ? "border-[var(--timeline-accent-current)]" : "border-border",
lit && status === "done" && "bg-[var(--timeline-accent-current)] text-primary-foreground",
)}
>
{status === "done" ? (
<svg viewBox="0 0 16 16" className={cn("size-3.5 transition-opacity duration-300", lit ? "opacity-100" : "opacity-0")} fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round">
<path d="M3.5 8.5l3 3 6-7" />
</svg>
) : (
<motion.span
className="size-2 rounded-full bg-[var(--timeline-accent-current)]"
initial={false}
animate={{ scale: lit ? 1 : 0 }}
transition={instant ?? SPRING_BOUNCY}
/>
)}
{status === "current" && lit && (
<span className="absolute inset-[-5px] animate-ping rounded-full border border-[var(--timeline-accent-current)] opacity-40 [animation-duration:2s] motion-reduce:hidden" />
)}
</motion.span>
</div>
<div className={cn("min-w-0", last ? "pb-0" : "pb-10")}>
{item.date && <p className={cn("text-xs font-medium uppercase tracking-wide text-muted-foreground", split && "sm:hidden")}>{item.date}</p>}
<p className={cn("font-medium text-pretty", item.date && "mt-1", split && "sm:mt-0", status === "upcoming" && "text-muted-foreground")}>
{status && <span className="sr-only">{statusText[status]}: </span>}
{item.title}
</p>
{item.description && <div className="mt-1 text-sm leading-relaxed text-muted-foreground text-pretty">{item.description}</div>}
</div>
</motion.li>
);
})}
</ol>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| items | TimelineEvent[] | — | Events in order: title, date, description, optional status (done, current, upcoming). |
| progress | "scroll" | "status" | "scroll" | Beam that follows your scroll, or a line lit up to the current event. |
| layout | "stacked" | "split" | "stacked" | Dates above titles, or in a sticky column of their own (from sm). |
| scrollContainer | RefObject<HTMLElement> | the page | The element that scrolls, for a timeline inside a scrollable panel. |
| animate | boolean | true | Ease events in as they scroll into view. |
| --timeline-accent | CSS variable | --primary | Colour of the beam and lit markers. |
Accessibility
- An ordered list; each event's status is spoken ("Completed", "In progress", "Upcoming"), not only drawn.
- The rail, beam and markers are decorative and hidden from screen readers.
- Under prefers-reduced-motion the beam tracks the scroll exactly, without easing, and events simply show.
Changelog
- v1.0.0Latest
First release.