Data Display

Timeline

A vertical activity timeline whose rail draws in on scroll and entries blur-rise with a stagger, exactly once, with tabular-nums timestamps.

Install

npx shadcn@latest add @paragon/timeline

timeline.tsx

"use client";

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

interface TimelineContextValue {
  /** Entries have been revealed (or never animate). */
  shown: boolean;
  /** Reveal with opacity only — no rise, no blur. */
  reduced: boolean;
}

const TimelineContext = React.createContext<TimelineContextValue>({
  shown: true,
  reduced: true,
});

const TimelineIndexContext = React.createContext(0);

export interface TimelineProps extends React.ComponentProps<"ol"> {
  /** Renders everything in place with no reveal animation. */
  static?: boolean;
  /** Draws the vertical rail connecting the nodes. */
  showConnector?: boolean;
}

/**
 * A vertical activity timeline. On first scroll into view the rail draws
 * in (scaleY from the top — a transform, not a height) and entries
 * blur-rise with a 60ms stagger. Both run exactly once; subsequent
 * scrolling never re-triggers them. Under reduced motion everything
 * reveals with opacity alone.
 */
export function Timeline({
  static: isStatic = false,
  showConnector = true,
  className,
  children,
  ...props
}: TimelineProps) {
  const ref = React.useRef<HTMLOListElement>(null);
  const inView = useInView(ref, { once: true, margin: "0px 0px -48px 0px" });
  const reducedMotion = useReducedMotion() ?? false;
  const shown = isStatic || inView;
  const reduced = reducedMotion || isStatic;
  const items = React.Children.toArray(children);

  return (
    <TimelineContext.Provider value={{ shown, reduced }}>
      <ol ref={ref} data-slot="timeline" className={cn("relative", className)} {...props}>
        {showConnector && (
          <div
            aria-hidden
            className={cn(
              // 11.5px = 24px node / 2 − 0.5px stroke, so the rail runs dead
              // center through every node dot. top/bottom-3 = node radius.
              "pointer-events-none absolute top-3 bottom-3 left-[11.5px] w-px origin-top bg-border",
              reduced
                ? cn(
                    "transition-opacity duration-(--duration-base) ease-(--ease-out)",
                    shown ? "opacity-100" : "opacity-0",
                  )
                : cn(
                    "transition-transform duration-(--duration-slow) ease-(--ease-out)",
                    shown ? "scale-y-100" : "scale-y-0",
                  ),
            )}
          />
        )}
        {items.map((child, i) => (
          <TimelineIndexContext.Provider key={i} value={i}>
            {child}
          </TimelineIndexContext.Provider>
        ))}
      </ol>
    </TimelineContext.Provider>
  );
}

type TimelineStatus = "default" | "success" | "warning" | "destructive";

// Node tint per semantic status — a filled dot for statuses, quiet for default.
const statusNode: Record<TimelineStatus, string> = {
  default: "bg-card text-muted-foreground shadow-border",
  success: "bg-success text-success-foreground",
  warning: "bg-warning text-warning-foreground",
  destructive: "bg-destructive text-destructive-foreground",
};

export interface TimelineItemProps extends React.ComponentProps<"li"> {
  /** Node icon on the rail. Defaults to a small dot. */
  icon?: React.ReactNode;
  /** Semantic status tint for the node. */
  status?: TimelineStatus;
}

export function TimelineItem({
  icon,
  status = "default",
  className,
  children,
  ...props
}: TimelineItemProps) {
  const { shown, reduced } = React.useContext(TimelineContext);
  const index = React.useContext(TimelineIndexContext);

  return (
    <li
      data-slot="timeline-item"
      data-status={status}
      style={{ transitionDelay: shown ? `${index * 60}ms` : undefined }}
      className={cn(
        "relative grid grid-cols-[24px_minmax(0,1fr)] gap-x-4 pb-8 last:pb-0",
        reduced
          ? cn(
              "transition-opacity duration-(--duration-base) ease-(--ease-out)",
              shown ? "opacity-100" : "opacity-0",
            )
          : cn(
              "transition-[opacity,transform,filter] duration-(--duration-base) ease-(--ease-out)",
              shown
                ? "translate-y-0 opacity-100 blur-none"
                : "translate-y-3 opacity-0 blur-[4px]",
            ),
        className,
      )}
      {...props}
    >
      <div
        aria-hidden
        className={cn(
          "z-[1] flex size-6 items-center justify-center self-start rounded-full [&_svg]:size-3.5",
          statusNode[status],
        )}
      >
        {icon ??
          (status === "default" ? (
            <span className="size-1.5 rounded-full bg-muted-foreground/60" />
          ) : (
            <span className="size-2 rounded-full bg-current" />
          ))}
      </div>
      <div className="min-w-0 pt-0.5">{children}</div>
    </li>
  );
}

export function TimelineTitle({
  className,
  ...props
}: React.ComponentProps<"p">) {
  return (
    <p
      data-slot="timeline-title"
      className={cn("text-sm font-medium text-foreground", className)}
      {...props}
    />
  );
}

export function TimelineDescription({
  className,
  ...props
}: React.ComponentProps<"p">) {
  return (
    <p
      data-slot="timeline-description"
      className={cn("mt-0.5 text-sm text-muted-foreground", className)}
      {...props}
    />
  );
}

export interface TimelineTimeProps extends React.ComponentProps<"time"> {}

export function TimelineTime({ className, ...props }: TimelineTimeProps) {
  return (
    <time
      data-slot="timeline-time"
      className={cn(
        "mt-1 block text-xs text-muted-foreground/80 tabular-nums",
        className,
      )}
      {...props}
    />
  );
}