Skip to content
Uxelith
Sign in
Freev1.0.0Data

Timeline

A vertical run of events — changelog, roadmap or order progress — with a beam of light that draws down the line as you scroll and lights each event as it passes. Dates can take a sticky column of their own.

pnpm dlx shadcn add …/r/timeline.json
Licence
MIT
Packages
3
Updated
10 Oct 2026
Live

Installation

  1. Run this in a project set up with shadcn/ui:

    $ pnpm dlx shadcn@latest add https://uxelith.com/r/timeline.json
  2. It adds these packages for you:

    clsxtailwind-mergemotion
  3. The source lands in your components folder as timeline.tsx. It's yours: change anything.

Usage

example.tsx
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.

timeline.tsx
"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

PropTypeDefaultDescription
itemsTimelineEvent[]—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).
scrollContainerRefObject<HTMLElement>the pageThe element that scrolls, for a timeline inside a scrollable panel.
animatebooleantrueEase events in as they scroll into view.
--timeline-accentCSS variable--primaryColour 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

  1. v1.0.0Latest

    First release.

More in Data

All 9 in Data
  • DataPro

    Area Chart

    Trends over time: lines draw themselves in, the crosshair glides between points, the area you've scrubbed stays bright, and the tooltip shows the change from the point before.

  • DataFree

    Avatar Stack

    Overlapping avatars that spread on hover, with a name tooltip that swings with the pointer, status dots and a +N list of everyone else. Photos or initials.

  • DataPro

    Bar Chart

    A responsive bar chart, grouped or stacked: bars rise in one after another, the hovered column lights up and the tooltip glides, with keyboard navigation and a hidden data table.