Timeline
An ordered list of events with one hairline rail running behind 24px dots.
Server-compatible: no hooks, no client boundary. Each item draws its own segment of the rail — from under its dot to the next item's dot, and none at all on the last — so the line never shows above the first dot or below the last however tall the rows are; each dot rings itself in the page background to sit over it. Tone is the item's own axis: default is near-ink for an ordinary step and neutral is quieter still, with the four status tones reserved for steps that actually carry status. title is content, not the HTML tooltip attribute, so it is omitted from the li props. Children render under the description, for anything a description cannot hold.
Install
npx shadcn@latest add @vibra/timelineNeeds the @vibra registry in your components.json — set it up once.
Examples
import {
CircleCheckIcon,
GitCommitVerticalIcon,
GitPullRequestIcon,
RocketIcon,
TriangleAlertIcon,
} from "lucide-react"
import { Timeline, TimelineItem } from "@/components/ui/timeline"
export default function TimelineDemo() {
return (
<Timeline className="w-full max-w-lg">
<TimelineItem
icon={<GitPullRequestIcon />}
tone="info"
time="9:12 AM"
title="Opened PR #482"
description="Jackson Lee · fix(checkout): guard against an empty cart"
/>
<TimelineItem
icon={<GitCommitVerticalIcon />}
time="10:04 AM"
title="Pushed 3 commits"
description="Rebased onto main and rewrote the cart guard"
/>
<TimelineItem
icon={<TriangleAlertIcon />}
tone="warning"
time="10:41 AM"
title="Preview build failed"
description="Type error in cart-summary.tsx — fixed on the next push"
/>
<TimelineItem
icon={<CircleCheckIcon />}
tone="success"
time="11:26 AM"
title="Approved by Olivia Martin"
description="2 files changed · 41 additions · 7 deletions"
/>
<TimelineItem
icon={<RocketIcon />}
tone="success"
time="11:38 AM"
title="Deployed to production"
description="Build 4c1a7f2 · 1m 48s"
/>
</Timeline>
)
}Tones
All six dots side by side, from the near-ink default to danger.
import { Timeline, TimelineItem, type TimelineTone } from "@/components/ui/timeline"
const steps: { tone: TimelineTone; title: string; description: string }[] = [
{ tone: "default", title: "Step queued", description: "An ordinary step: near-ink, no colour spent" },
{ tone: "neutral", title: "Step skipped", description: "Quieter still — nothing happened here" },
{ tone: "info", title: "Provisioning", description: "In flight, and worth watching" },
{ tone: "success", title: "Health check passed", description: "All 12 instances answered" },
{ tone: "warning", title: "Rollout paused", description: "Error rate above 0.5% for 2 minutes" },
{ tone: "danger", title: "Rolled back", description: "Reverted to build 3b8e0c1" },
]
export default function TimelineTones() {
return (
<Timeline className="w-full max-w-lg">
{steps.map((step) => (
<TimelineItem
key={step.tone}
tone={step.tone}
title={step.title}
description={step.description}
/>
))}
</Timeline>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| Timeline.className | string | — | Merged onto the ol root, which draws the rail; the remaining ol props are spread onto it. |
| title | React.ReactNode | — | The event itself — what happened. |
| description | React.ReactNode | — | A quieter second line under the title. |
| time | React.ReactNode | — | Trails the title — a timestamp, a duration, a version. |
| icon | React.ReactNode | — | Sits inside the dot. Defaults to a small filled circle. |
| tone | "default" | "neutral" | "success" | "warning" | "danger" | "info" | "default" | Paints the dot from its muted background and matching foreground. |
| children | React.ReactNode | — | Extra content under the description — a diff box, a set of tags, a chart. |
Dependencies
Source
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
const timelineDotVariants = cva(
"relative z-10 flex size-6 shrink-0 items-center justify-center rounded-full ring-4 ring-background [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3",
{
variants: {
tone: {
// Near-ink for an ordinary step: colour stays reserved for status.
default: "bg-muted text-foreground",
neutral: "bg-muted text-muted-foreground",
success: "bg-success-muted text-success",
warning: "bg-warning-muted text-warning",
danger: "bg-danger-muted text-danger",
info: "bg-info-muted text-info",
},
},
defaultVariants: { tone: "default" },
}
)
export type TimelineTone = NonNullable<VariantProps<typeof timelineDotVariants>["tone"]>
/** An ordered list of events with one hairline rail running behind the dots. */
function Timeline({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="timeline"
className={cn("flex flex-col", className)}
{...props}
/>
)
}
// `title` is content here, not the HTML tooltip attribute.
export type TimelineItemProps = Omit<React.ComponentProps<"li">, "title"> & {
/** Sits inside the dot. Defaults to a small filled circle. */
icon?: React.ReactNode
tone?: TimelineTone
/** Trails the title — a timestamp, a duration, a version. */
time?: React.ReactNode
title: React.ReactNode
description?: React.ReactNode
}
/** One event on a Timeline: a dot, a title with its time, and an optional second line. */
function TimelineItem({
className,
icon,
tone = "default",
time,
title,
description,
children,
...props
}: TimelineItemProps) {
return (
<li
data-slot="timeline-item"
data-tone={tone}
// Each item draws its own segment of the rail, from under its dot to the
// next item's dot, and the last item draws none — so the line never
// shows above the first dot or below the last however tall the rows are.
className={cn(
"relative flex gap-3 pb-5 last:pb-0",
"before:absolute before:top-6 before:bottom-0 before:start-[11px] before:w-px before:bg-border last:before:hidden",
className
)}
{...props}
>
<span data-slot="timeline-item-dot" className={cn(timelineDotVariants({ tone }))}>
{icon ?? (
<CircleIcon
data-slot="timeline-item-default-icon"
aria-hidden="true"
className="size-2 fill-current"
/>
)}
</span>
<div data-slot="timeline-item-content" className="flex min-w-0 flex-1 flex-col gap-0.5 pt-0.5">
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span data-slot="timeline-item-title" className="text-sm font-medium">
{title}
</span>
{time ? (
<span
data-slot="timeline-item-time"
className="text-xs tabular-nums whitespace-nowrap text-muted-foreground"
>
{time}
</span>
) : null}
</div>
{description ? (
<div data-slot="timeline-item-description" className="text-sm text-muted-foreground">
{description}
</div>
) : null}
{children}
</div>
</li>
)
}
export { Timeline, TimelineItem, timelineDotVariants }