Timeline
Compound API: Timeline, TimelineItem, TimelineNode, TimelineContent. Every item's content is wrapped in Reveal below to prove the two compose cleanly.
Default (left)
- 2021
Company founded
Started as a two-person studio building internal tools.
- 2023
Series A
Raised funding to grow the design systems team.
- 2025
1,000 customers
Crossed a thousand paying teams on the platform.
Alternating
- Q1
Research
Interviewed 40 teams about their component workflow.
- Q2
Prototype
Shipped the first token layer and three primitives.
- Q3
Private beta
Twelve design partners installing components daily.
- Q4
General availability
Public launch of the full registry.
With icons
Launched v1
Public release of the CLI.
200th release
Shipped the 200th tagged version.
Registry opened
Third-party registries went live.
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).
- Completed.Mon
Order placed
We received your order.
- Completed.Mon
Payment confirmed
Your card was charged.
- In progress.Tue
Preparing shipment
Your order is being packed.
- Thu
Out for delivery
Estimated Thursday.
Installation
$ npx shadcn@latest add https://deepmoon.dev/r/timeline.jsonnpm dependencies: lucide-react
Props
| Prop | Type | Required |
|---|---|---|
| variant | TimelineVariant | No |
| Prop | Type | Required |
|---|---|---|
| status | TimelineNodeStatus | No |
| icon | React.ReactNode | No |
| Prop | Type | Required |
|---|---|---|
| title | React.ReactNode | Yes |
| description | React.ReactNode | No |
| timestamp | React.ReactNode | No |
| headingLevel | "h2" | "h3" | "h4" | "h5" | "h6" | No |
Code
"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>
);
}