deepmoon

Timeline

Compound API: Timeline, TimelineItem, TimelineNode, TimelineContent. Every item's content is wrapped in Reveal below to prove the two compose cleanly.

Default (left)

  1. 2021

    Company founded

    Started as a two-person studio building internal tools.

  2. 2023

    Series A

    Raised funding to grow the design systems team.

  3. 2025

    1,000 customers

    Crossed a thousand paying teams on the platform.

Alternating

  1. Q1

    Research

    Interviewed 40 teams about their component workflow.

  2. Q2

    Prototype

    Shipped the first token layer and three primitives.

  3. Q3

    Private beta

    Twelve design partners installing components daily.

  4. Q4

    General availability

    Public launch of the full registry.

With icons

  1. Launched v1

    Public release of the CLI.

  2. 200th release

    Shipped the 200th tagged version.

  3. Registry opened

    Third-party registries went live.

  4. v2 preview

    Early access to the next major version.

Active / completed states

Node states are also shape-coded, not color-only: empty ring (default), ring with a dot (active), filled circle with a check (completed).

  1. Completed.
    Mon

    Order placed

    We received your order.

  2. Completed.
    Mon

    Payment confirmed

    Your card was charged.

  3. In progress.
    Tue

    Preparing shipment

    Your order is being packed.

  4. Thu

    Out for delivery

    Estimated Thursday.

Installation

$ npx shadcn@latest add https://deepmoon.dev/r/timeline.json

npm dependencies: lucide-react

Props

TimelineProps
PropTypeRequired
variantTimelineVariantNo
TimelineNodeProps
PropTypeRequired
statusTimelineNodeStatusNo
iconReact.ReactNodeNo
TimelineContentProps
PropTypeRequired
titleReact.ReactNodeYes
descriptionReact.ReactNodeNo
timestampReact.ReactNodeNo
headingLevel"h2" | "h3" | "h4" | "h5" | "h6"No

Code

timeline.tsx
"use client";

import * as React from "react";
import { Check } from "lucide-react";

import { cn } from "@/lib/utils";

export type TimelineVariant = "left" | "alternating";
export type TimelineNodeStatus = "default" | "active" | "completed";

interface TimelineContextValue {
  variant: TimelineVariant;
  total: number;
}

const TimelineContext = React.createContext<TimelineContextValue | null>(null);

function useTimelineContext(component: string) {
  const context = React.useContext(TimelineContext);
  if (!context) {
    throw new Error(`<${component}> must be used within <Timeline>.`);
  }
  return context;
}

const TimelineItemIndexContext = React.createContext(0);

export interface TimelineProps extends React.ComponentPropsWithoutRef<"ol"> {
  variant?: TimelineVariant;
}

/**
 * Root list. Assigns each direct child (expected to be <TimelineItem>) a
 * stable index via context, purely derived from render order — no refs or
 * effects, so it's identical on the server and first client render.
 */
export function Timeline({ variant = "left", className, children, ...props }: TimelineProps) {
  const items = React.Children.toArray(children);

  return (
    <TimelineContext.Provider value={{ variant, total: items.length }}>
      <ol
        role="list"
        data-variant={variant}
        className={cn("relative flex list-none flex-col", className)}
        {...props}
      >
        {items.map((child, index) => (
          <TimelineItemIndexContext.Provider key={index} value={index}>
            {child}
          </TimelineItemIndexContext.Provider>
        ))}
      </ol>
    </TimelineContext.Provider>
  );
}

export interface TimelineItemProps extends React.ComponentPropsWithoutRef<"li"> {}

/**
 * A single entry. Renders a fixed DOM order — connector, node, content —
 * regardless of variant or which side content visually sits on; only CSS
 * grid-column placement changes between variants/breakpoints, so reading
 * order, tab order, and the "alternating" DOM never scramble.
 *
 * Any wrapper placed around the non-node children (e.g. <Reveal>) still
 * lands in the content grid cell, since placement is applied by this
 * component to its own wrapper <div>, not to the children themselves.
 */
export function TimelineItem({ className, children, ...props }: TimelineItemProps) {
  const { variant, total } = useTimelineContext("TimelineItem");
  const index = React.useContext(TimelineItemIndexContext);
  const isLast = index === total - 1;
  const side: "start" | "end" = variant === "alternating" && index % 2 === 1 ? "end" : "start";
  const alternating = variant === "alternating";

  let node: React.ReactNode = null;
  const content: React.ReactNode[] = [];
  React.Children.forEach(children, (child) => {
    if (React.isValidElement(child) && child.type === TimelineNode) {
      node = child;
    } else {
      content.push(child);
    }
  });

  return (
    <li
      role="listitem"
      data-side={side}
      className={cn(
        "relative grid items-start gap-x-[var(--timeline-gutter)] grid-cols-[auto_1fr]",
        alternating && "sm:grid-cols-[1fr_auto_1fr]",
        !isLast && "pb-[var(--timeline-item-gap)]",
        className,
      )}
      {...props}
    >
      {!isLast && (
        <span
          aria-hidden="true"
          className={cn(
            "absolute top-[var(--timeline-node-size)] bottom-0 w-px -translate-x-1/2 bg-border",
            "left-[calc(var(--timeline-node-size)/2)]",
            alternating && "sm:left-1/2",
          )}
        />
      )}
      <div className={cn("relative z-10 col-start-1", alternating && "sm:col-start-2")}>{node}</div>
      <div
        className={cn(
          "flex flex-col gap-1 col-start-2",
          alternating &&
            (side === "start"
              ? "sm:col-start-1 sm:items-end sm:text-right"
              : "sm:col-start-3 sm:items-start sm:text-left"),
        )}
      >
        {content}
      </div>
    </li>
  );
}

export interface TimelineNodeProps extends React.ComponentPropsWithoutRef<"span"> {
  status?: TimelineNodeStatus;
  icon?: React.ReactNode;
}

const statusLabel: Record<TimelineNodeStatus, string | null> = {
  default: null,
  active: "In progress. ",
  completed: "Completed. ",
};

/**
 * The marker dot. Purely decorative visually (aria-hidden), so status is
 * announced separately via a sibling sr-only span — text nested inside an
 * aria-hidden element would never reach assistive tech. States are also
 * distinguished by shape, not color alone: empty ring (default), ring with
 * an inner dot (active), filled circle with a check (completed).
 */
export function TimelineNode({ status = "default", icon, className, children, ...props }: TimelineNodeProps) {
  const label = statusLabel[status];

  return (
    <>
      {label ? <span className="sr-only">{label}</span> : null}
      <span
        aria-hidden="true"
        className={cn(
          "relative flex size-[var(--timeline-node-size)] shrink-0 items-center justify-center rounded-full border-[length:var(--timeline-node-border-width)] bg-background text-muted-foreground",
          status === "default" && "border-border",
          status === "active" && "border-primary text-primary",
          status === "completed" && "border-primary bg-primary text-primary-foreground",
          className,
        )}
        {...props}
      >
        {status === "active" && (
          <span className="absolute inset-0 -z-10 rounded-full bg-primary/40 motion-safe:animate-ping" />
        )}
        {icon ??
          children ??
          (status === "completed" ? (
            <Check className="size-[calc(var(--timeline-node-size)*0.5)]" />
          ) : status === "active" ? (
            <span className="size-[calc(var(--timeline-node-size)*0.35)] rounded-full bg-primary" />
          ) : null)}
      </span>
    </>
  );
}

export interface TimelineContentProps extends Omit<React.ComponentPropsWithoutRef<"div">, "title"> {
  title: React.ReactNode;
  description?: React.ReactNode;
  timestamp?: React.ReactNode;
  /** Heading level for `title` — pick what fits the page's own hierarchy. */
  headingLevel?: "h2" | "h3" | "h4" | "h5" | "h6";
}

export function TimelineContent({
  title,
  description,
  timestamp,
  headingLevel: Heading = "h3",
  className,
  children,
  ...props
}: TimelineContentProps) {
  return (
    <div className={cn("flex flex-col gap-1", className)} {...props}>
      {timestamp ? (
        <span className="text-sm font-medium text-muted-foreground">{timestamp}</span>
      ) : null}
      <Heading className="text-base font-semibold leading-snug text-foreground">{title}</Heading>
      {description ? (
        <p className="text-sm leading-relaxed text-muted-foreground">{description}</p>
      ) : null}
      {children}
    </div>
  );
}