# Timeline

An ember line that draws itself as you scroll, with each entry rising into view.

**Category:** Display  
**Exports:** `Timeline`  
**npm dependencies:** motion (+ clsx, tailwind-merge for `cn`)

## Install

```bash
npx shadcn@latest add https://charcoalui.com/r/timeline.json
```
The file lands at `components/ui/timeline.tsx`; import from `@/components/ui/timeline`.

## API — Timeline
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `items` | `{ date: string; title: string; body?: ReactNode }[]` | — |  |
| `container` | `RefObject<HTMLElement>` | — |  |

## Example — Timeline

```tsx
import * as React from "react";
import { Timeline } from "@/components/ui/timeline";

export default function TimelineDemo() {
  const ref = React.useRef<HTMLDivElement>(null);
  return (
    <div ref={ref} className="h-80 w-full max-w-md overflow-y-auto rounded-xl border bg-background px-6 py-8">
      <p className="mb-8 text-sm text-muted-foreground">Scroll to draw the line.</p>
      <Timeline
        container={ref}
        items={[
          { date: "4 Oct 2026", title: "Charcoal UI 1.0", body: "110 components, a shadcn registry and a Cloudflare Worker." },
          { date: "28 Sep 2026", title: "Range slider family", body: "Six sliders that share one accessible core." },
          { date: "21 Sep 2026", title: "The ember accent", body: "One warm colour, saved for the moment that matters." },
          { date: "12 Sep 2026", title: "First sketch", body: "A charcoal palette and a slider that bounces on every step." },
        ]}
      />
      <div className="h-24" />
    </div>
  );
}
```

## Source — components/ui/timeline.tsx

```tsx
"use client";

import * as React from "react";
import { motion, useReducedMotion, useScroll, useSpring } from "motion/react";
import { cn } from "@/lib/utils";

export type TimelineItem = { date: string; title: string; body?: React.ReactNode };

export type TimelineProps = {
  items: TimelineItem[];
  /** Scroll container; defaults to the page. */
  container?: React.RefObject<HTMLElement | null>;
  className?: string;
};

/** The line draws itself as you scroll, and each entry rises into view. */
export function Timeline({ items, container, className }: TimelineProps) {
  const reduce = useReducedMotion();
  const ref = React.useRef<HTMLOListElement>(null);
  const { scrollYProgress } = useScroll({ target: ref, container, offset: ["start 80%", "end 60%"] });
  const scaleY = useSpring(scrollYProgress, { stiffness: 200, damping: 30 });

  return (
    <ol ref={ref} className={cn("relative grid gap-10 pl-8", className)}>
      <span aria-hidden className="absolute bottom-2 left-[7px] top-2 w-0.5 rounded-full bg-muted" />
      <motion.span aria-hidden className="absolute bottom-2 left-[7px] top-2 w-0.5 origin-top rounded-full bg-ember" style={{ scaleY: reduce ? 1 : scaleY }} />
      {items.map((item, i) => (
        <motion.li
          key={i}
          className="relative"
          initial={{ opacity: 0, y: reduce ? 0 : 16 }}
          whileInView={{ opacity: 1, y: 0 }}
          viewport={{ once: true, root: container, amount: 0.6 }}
          transition={{ type: "spring", stiffness: 260, damping: 28 }}
        >
          <span aria-hidden className="absolute -left-8 top-1 grid size-4 place-items-center rounded-full border-2 border-ember bg-background">
            <span className="size-1.5 rounded-full bg-ember" />
          </span>
          <time className="font-mono text-xs text-muted-foreground">{item.date}</time>
          <h3 className="mt-1 font-semibold">{item.title}</h3>
          {item.body && <div className="mt-1 text-sm leading-6 text-muted-foreground">{item.body}</div>}
        </motion.li>
      ))}
    </ol>
  );
}
```