# Timeline

The Timeline component lays events down a vertical rail that fills as the reader scrolls through them, scaling a single element rather than resizing it so the fill costs nothing on the main thread.

- **Category:** Component
- **Docs:** https://uibeats.com/docs/component/timeline
- **Registry:** https://uibeats.com/r/timeline.json
- **License:** MIT
- **npm dependencies:** `motion`

## Installation

```bash
npx shadcn@latest add https://uibeats.com/r/timeline.json
```

Or install the dependencies and copy the source below:

```bash
npm install motion
```

## When to use

For a sequence where the order carries meaning: a changelog, a roadmap, an onboarding walkthrough, a company history. A list of items that merely happen to be numbered is better as a list: the rail promises the reader that each entry follows from the one above it.

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `items` | `TimelineItem[]` | `-` | The events to render, in order (required) |
| `container` | `RefObject<HTMLElement \| null>` | `-` | Scroll container to track. Omit to track the window |
| `nodeSize` | `number` | `12` | Diameter of each node, in pixels |
| `smooth` | `boolean` | `true` | Ease the rail toward the true position instead of tracking it exactly |
| `className` | `string` | `''` | Additional CSS classes applied to the list |
| `TimelineItem: id` | `string` | `-` | Unique key for the entry |
| `TimelineItem: title` | `string` | `-` | Heading for the entry |
| `TimelineItem: meta` | `string` | `-` | Small line above the title: a date, a version, a stage |
| `TimelineItem: body` | `string` | `-` | The entry's copy |

## Example usage

```tsx
"use client";

import { useRef } from "react";
import { Timeline } from "@/components/demo/component/timeline";

const milestones = [
  {
    id: "scaffold",
    meta: "Step one",
    title: "Scaffold the component",
    body: "One command writes the four files and edits the three registries that have to know about it.",
  },
  {
    id: "build",
    meta: "Step two",
    title: "Write the animation",
    body: "Motion lives in the same file as the markup, so there is one place to change how it feels.",
  },
  {
    id: "document",
    meta: "Step three",
    title: "Fill in the props table",
    body: "The table is the source of truth: the playground controls are derived from it, not written twice.",
  },
  {
    id: "ship",
    meta: "Step four",
    title: "Open the pull request",
    body: "The suite checks the reduced-motion guard, the title budget and a pixel baseline before it merges.",
  },
];

const TimelineUsage = () => {
  const scrollArea = useRef<HTMLDivElement>(null);

  return (
    <div
      ref={scrollArea}
      className="h-72 w-full max-w-sm overflow-y-auto rounded-xl border bg-card p-5"
    >
      <Timeline items={milestones} container={scrollArea} />
    </div>
  );
};

export default TimelineUsage;
```

## Source

Save as `components/ui/timeline.tsx`.

```tsx
"use client";

import {
  motion,
  useReducedMotion,
  useScroll,
  useSpring,
  useTransform,
} from "motion/react";
import { useRef, type RefObject } from "react";
import { cn } from "@/lib/utils";

interface TimelineItem {
  id: string;
  title: string;
  /** Small line above the title: a date, a version, a stage. */
  meta?: string;
  body: string;
}

interface TimelineProps {
  items: TimelineItem[];
  /**
   * Scroll container to track. Omit to track the window.
   *
   * The rail is measured against this, so a timeline inside a scrolling panel
   * has to name that panel or the fill will follow the page instead.
   */
  container?: RefObject<HTMLElement | null>;
  /** Diameter of each node, in pixels. */
  nodeSize?: number;
  /** Ease the rail toward the true position instead of tracking it exactly. */
  smooth?: boolean;
  className?: string;
}

/**
 * A vertical list of events with a rail that fills as it is read.
 *
 * The fill is `scaleY` on a single element rather than a changing height, so
 * the browser never runs layout for a value that updates on every scroll
 * frame. Its progress comes from the list's own position in the scroller,
 * which is what keeps the filled length honest: the rail is as full as the
 * reader is far through the entries, not as far as some fraction of the page.
 *
 * The `offset` finishes the fill before the last entry leaves the viewport.
 * Measuring to `end end` means the rail only completes once the list's bottom
 * edge reaches the bottom of the screen, which for a list that ends above the
 * fold never happens at all.
 *
 * Entries reveal on entry rather than on the rail's progress so that each one
 * animates once, in place, however the reader arrives at it.
 */
export function Timeline({
  items,
  container,
  nodeSize = 12,
  smooth = true,
  className = "",
}: TimelineProps) {
  const prefersReducedMotion = useReducedMotion();
  const railRef = useRef<HTMLOListElement>(null);

  const { scrollYProgress } = useScroll({
    target: railRef,
    ...(container ? { container } : {}),
    offset: ["start 85%", "end 65%"],
  });

  const smoothed = useSpring(scrollYProgress, {
    stiffness: 120,
    damping: 26,
    restDelta: 0.001,
  });

  /*
   * The fill survives reduced motion: it moves only because the reader is
   * scrolling, and pinning it at zero would leave a rail that is permanently
   * wrong. The spring does not: it keeps travelling after the scroll stops.
   */
  const scaleY = useTransform(
    smooth && !prefersReducedMotion ? smoothed : scrollYProgress,
    (value) => Math.max(value, 0),
  );

  return (
    <ol ref={railRef} className={cn("relative w-full", className)}>
      {/* The unfilled rail, and the fill that rides on top of it. */}
      <span
        aria-hidden="true"
        className="absolute top-2 bottom-2 w-px bg-border"
        style={{ left: nodeSize / 2 }}
      />
      <motion.span
        aria-hidden="true"
        className="absolute top-2 bottom-2 w-px origin-top bg-primary"
        style={{ left: nodeSize / 2, scaleY }}
      />

      {items.map((item, index) => (
        <motion.li
          key={item.id}
          initial={
            prefersReducedMotion
              ? false
              : { opacity: 0, y: 16, filter: "blur(4px)" }
          }
          whileInView={{ opacity: 1, y: 0, filter: "blur(0px)" }}
          viewport={{ once: true, margin: "0px 0px -15% 0px" }}
          transition={{
            duration: prefersReducedMotion ? 0 : 0.5,
            ease: [0.22, 1, 0.36, 1],
            // A short stagger only within the entries that arrive together.
            delay: prefersReducedMotion ? 0 : Math.min(index, 3) * 0.06,
          }}
          className="relative pb-8 last:pb-0"
          style={{ paddingLeft: nodeSize + 20 }}
        >
          <span
            aria-hidden="true"
            className="absolute top-2 rounded-full border-2 border-background bg-primary"
            style={{ left: 0, width: nodeSize, height: nodeSize }}
          />

          {item.meta ? (
            <p className="text-xs text-muted-foreground">{item.meta}</p>
          ) : null}
          <h3 className="text-sm font-medium">{item.title}</h3>
          <p className="mt-1 text-sm leading-relaxed text-muted-foreground">
            {item.body}
          </p>
        </motion.li>
      ))}
    </ol>
  );
}

export default Timeline;
```

## Credits

Written by [Claude Code](https://claude.com/claude-code).
