# Ink Timeline (`ink-timeline`)

> A process timeline drawn in ink — the rail fills as you scroll, and each step ignites the moment its dot crosses the center of the viewport.

- **Docs:** https://www.mellowui.com/components/ink-timeline
- **Markdown:** https://www.mellowui.com/components/ink-timeline.md
- **Registry:** https://www.mellowui.com/r/ink-timeline.json
- **Tool prompt:** https://www.mellowui.com/api/prompt/ink-timeline
- **Categories:** display, scroll, animation
- **Dependencies:** motion

## AI prompt

Add an InkTimeline component from the mellow library — a process timeline drawn in ink. The rail fills as you scroll, and each step ignites the moment its dot crosses the center of the viewport. Pass `steps` with title, optional kicker and description. For an embedded scroller pass `scrollContainerRef`. Tune the ignite with `stiffness` and `damping`. Reduced-motion draws the rail complete and lights every step.

## Install

```bash
npx shadcn@latest add https://mellowui.com/r/ink-timeline.json
```

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `steps` | `InkTimelineStep[]` | — | Timeline entries — title, optional kicker and description. |
| `scrollContainerRef` | `RefObject<HTMLElement \| null>` | — | Scrollable ancestor when embedded; defaults to the window. |
| `stiffness` | `number` | `240` | Ignite spring stiffness — higher snaps the dot in harder. |
| `damping` | `number` | `28` | Ignite spring damping — lower overshoots more. |
| `className` | `string` | — | Additional CSS classes. |

## Design notes

- Client component (`"use client"`) — required for animation and refs.
- Uses Mellow design tokens: `--ink`, `--ink-rgb`, `--background`, `--background-rgb`, `--rule`, `--font-sans`, `--font-serif`, `--font-mono`. Tokens auto-flip between light and dark themes — never hardcode colors.
- Respects `prefers-reduced-motion: reduce` where applicable.
- Self-contained — no shared utilities. Copy-paste, not a package.

## Source

```tsx
"use client";

import React, { useEffect, useRef, useState } from "react";
import { motion, useMotionValue, useReducedMotion } from "motion/react";

export interface InkTimelineStep {
  title: string;
  description?: React.ReactNode;
  /** Small mono label above the title, e.g. a date or phase. */
  kicker?: string;
}

export interface InkTimelineProps {
  steps: InkTimelineStep[];
  /** Scrollable ancestor when embedded; defaults to the window. */
  scrollContainerRef?: React.RefObject<HTMLElement | null>;
  /** Ignite spring stiffness — higher snaps the dot in harder. */
  stiffness?: number;
  /** Ignite spring damping — lower overshoots more. */
  damping?: number;
  className?: string;
  style?: React.CSSProperties;
}

/**
 * A process timeline drawn in ink — the rail fills as you scroll, and each
 * step ignites the moment its dot crosses the center of the viewport.
 */
export function InkTimeline({
  steps,
  scrollContainerRef,
  stiffness = 240,
  damping = 28,
  className,
  style,
}: InkTimelineProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  const dotRefs = useRef<(HTMLSpanElement | null)[]>([]);
  const fill = useMotionValue(0);
  const reduced = useReducedMotion();
  const [active, setActive] = useState<boolean[]>(() => steps.map(() => false));
  const [rail, setRail] = useState({ top: 11, height: 0 });

  useEffect(() => {
    const measure = () => {
      const root = rootRef.current;
      const first = dotRefs.current[0];
      const last = dotRefs.current[steps.length - 1];
      if (!root || !first || !last) return;
      const rootTop = root.getBoundingClientRect().top;
      const firstRect = first.getBoundingClientRect();
      const lastRect = last.getBoundingClientRect();
      const top = firstRect.top - rootTop + firstRect.height / 2;
      const bottom = lastRect.top - rootTop + lastRect.height / 2;
      setRail({ top, height: Math.max(0, bottom - top) });
    };
    measure();
    const ro = new ResizeObserver(measure);
    if (rootRef.current) ro.observe(rootRef.current);
    window.addEventListener("resize", measure);
    return () => {
      ro.disconnect();
      window.removeEventListener("resize", measure);
    };
  }, [steps.length]);

  useEffect(() => {
    if (reduced) {
      fill.set(1);
      setActive(steps.map(() => true));
      return;
    }
    const container = scrollContainerRef?.current ?? null;
    const scroller: HTMLElement | Window = container ?? window;

    const update = () => {
      const root = rootRef.current;
      if (!root) return;
      // Scrolled to the end — the line completes even if the last dot never
      // reaches the activation center.
      const atEnd = container
        ? container.scrollTop + container.clientHeight >=
          container.scrollHeight - 2
        : window.scrollY + window.innerHeight >=
          document.documentElement.scrollHeight - 2;
      if (atEnd) {
        fill.set(1);
        setActive((prev) =>
          prev.every(Boolean) ? prev : prev.map(() => true)
        );
        return;
      }
      const rootRect = root.getBoundingClientRect();
      const centerY = container
        ? container.getBoundingClientRect().top + container.clientHeight * 0.55
        : window.innerHeight * 0.55;
      const lastDot = dotRefs.current[steps.length - 1];
      const railEnd =
        lastDot && root
          ? lastDot.getBoundingClientRect().top +
            lastDot.getBoundingClientRect().height / 2 -
            rootRect.top
          : rootRect.height;
      fill.set(
        Math.min(1, Math.max(0, (centerY - rootRect.top) / Math.max(railEnd, 1)))
      );
      const next = dotRefs.current
        .slice(0, steps.length)
        .map((dot) =>
          dot ? dot.getBoundingClientRect().top + 11 < centerY : false
        );
      setActive((prev) =>
        prev.length === next.length && prev.every((v, i) => v === next[i])
          ? prev
          : next
      );
    };
    update();
    scroller.addEventListener("scroll", update, { passive: true });
    window.addEventListener("resize", update);
    return () => {
      scroller.removeEventListener("scroll", update);
      window.removeEventListener("resize", update);
    };
  }, [reduced, scrollContainerRef, steps.length, fill]);

  const spring = reduced
    ? ({ duration: 0 } as const)
    : ({ type: "spring", stiffness, damping } as const);

  return (
    <div
      ref={rootRef}
      className={["relative", className].filter(Boolean).join(" ")}
      style={style}
    >
      <div
        aria-hidden="true"
        className="absolute left-[10px] w-px bg-[rgba(var(--ink-rgb),0.14)]"
        style={{ top: rail.top, height: rail.height }}
      />
      <motion.div
        aria-hidden="true"
        className="absolute left-[10px] w-px origin-top bg-[var(--ink)]"
        style={{ top: rail.top, height: rail.height, scaleY: fill }}
      />
      <ol className="m-0 flex list-none flex-col gap-10 p-0">
        {steps.map((step, i) => {
          const on = active[i];
          return (
            <li key={i} className="relative flex gap-6">
              <span
                ref={(el) => {
                  dotRefs.current[i] = el;
                }}
                aria-hidden="true"
                className="relative z-[1] mt-0.5 flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-full border border-[rgba(var(--ink-rgb),0.25)] bg-[var(--background)]"
              >
                <motion.span
                  animate={{ scale: on ? 1 : 0 }}
                  transition={spring}
                  className="h-2 w-2 rounded-full bg-[var(--ink)]"
                />
              </span>
              <motion.div
                animate={{ opacity: on ? 1 : 0.35, y: on ? 0 : 10 }}
                transition={spring}
                className="min-w-0 pb-1"
              >
                {step.kicker && (
                  <div className="mb-1 [font-family:var(--font-mono)] text-[0.625rem] font-medium tracking-[0.16em] text-[rgba(var(--ink-rgb),0.45)] uppercase">
                    {step.kicker}
                  </div>
                )}
                <div className="[font-family:var(--font-sans)] text-base font-medium tracking-[-0.01em] text-[var(--ink)]">
                  {step.title}
                </div>
                {step.description && (
                  <div className="mt-1.5 [font-family:var(--font-sans)] text-[0.9375rem] leading-relaxed text-[rgba(var(--ink-rgb),0.6)]">
                    {step.description}
                  </div>
                )}
              </motion.div>
            </li>
          );
        })}
      </ol>
    </div>
  );
}

export default InkTimeline;

```

## Demo

```tsx
"use client";

import React, { useRef } from "react";
import { InkTimeline } from "../mellow/ink-timeline";

export const INK_TIMELINE_STEPS = [
  {
    kicker: "Step 01",
    title: "Pick a component",
    description:
      "Browse the registry and find the gesture your page is missing. Every entry ships with a live demo and full source.",
  },
  {
    kicker: "Step 02",
    title: "Install with the CLI",
    description:
      "One shadcn command copies the file into your project. No package, no lock-in — the code is yours from the first second.",
  },
  {
    kicker: "Step 03",
    title: "Theme it with tokens",
    description:
      "Components read the ink and background CSS variables, so they follow your light and dark themes without touching a line.",
  },
  {
    kicker: "Step 04",
    title: "Tune the motion",
    description:
      "Spring stiffness, stagger, distance — every value is a prop or a constant at the top of the file. Make it yours.",
  },
  {
    kicker: "Step 05",
    title: "Ship it",
    description:
      "Reduced-motion fallbacks and keyboard support are already in. Deploy without the accessibility audit surprise.",
  },
];

export default function InkTimelineDemo() {
  const containerRef = useRef<HTMLDivElement>(null);

  return (
    <div className="flex w-full flex-col items-center gap-3 p-3 sm:gap-4 sm:p-6">
      <div
        ref={containerRef}
        className="h-[240px] w-full max-w-xl overflow-y-auto overscroll-contain rounded-lg border border-[var(--rule)] px-4 py-5 sm:h-[420px] sm:px-8 sm:py-10"
      >
        <InkTimeline steps={INK_TIMELINE_STEPS} scrollContainerRef={containerRef} />
      </div>
      <p className="text-[0.8125rem] text-[rgba(var(--ink-rgb),0.35)]">
        Scroll inside the frame — steps ignite as they cross the center
      </p>
    </div>
  );
}

```
