Skip to content
Component installs need the registry setup— the Base UI shadcn project, the @vegastack namespace and the Cloudflare Access service token.
VegaStack Design

Timeline

Rail geometry for chronological records — a continuous connector with a node per entry; rows are Item parts, timestamps are RelativeTime.

Status
stable
Since
0.4.0
Accessibility pattern
ordered list of items

Last updated

  1. Today
  2. Deal moved to Won

    Acme renewal · $12,400

  3. Priya logged a call

    Pricing review with procurement

  4. Yesterday
  5. Proposal sent

Install

Add Timeline from the VegaStack registry. The CLI verifies the item's integrity hash before writing it.

pnpm dlx shadcn@latest add @vegastack/timeline

The same command installs the registry items it composes: @vegastack/marker.

Usage

import {
  Timeline,
  TimelineItem,
  TimelineSeparator,
} from "@/components/ui/timeline";

<Timeline aria-label="Activity">
  <TimelineSeparator>Today</TimelineSeparator>
  <TimelineItem node={<StatusIcon status="done" size="sm" label="" />}>
    <Item size="sm">
      <ItemContent>
        <ItemTitle>Deal moved to Won</ItemTitle>
      </ItemContent>
      <ItemContent className="text-muted-foreground">
        <RelativeTime date={when} now={now} refresh={false} />
      </ItemContent>
    </Item>
  </TimelineItem>
</Timeline>;

Timeline is rail geometry only — the continuous vertical connector with a node per entry. Everything a row needs already exists: Item parts for the event itself, RelativeTime for timestamps, Avatar for actors, and Marker's separator variant renders the group headers. There is deliberately no TimelineTitle/TimelineDescription — that would fork Item's vocabulary.

Scope

BehaviourWhere it lives
Row anatomy (title, media…)Item parts — compose them as children
Day grouping + midnight rollHost data work; TimelineSeparator renders the computed header
Live bottom-pinned feedsMessageScroller — a different job
VirtualizationNot needed: entries carry content-visibility render skipping built in

Anatomy

Timeline is a compound component. Every exported part, with the data-slot it renders (generated from the canonical source):

Timeline — data-slot="timeline"
TimelineItem — data-slot="timeline-connector" | "timeline-content" | "timeline-item" | "timeline-node" | "timeline-rail"
TimelineSeparator — data-slot="timeline-separator"

Examples

Anatomy

Timeline              <ol> — the record
├─ TimelineSeparator  group header via Marker variant="separator"
└─ TimelineItem       <li>: aria-hidden rail (node + connector) + content
   └─ Item            the event row — Item owns the anatomy

Status nodes and linked rows

The rail node is decorative (aria-hidden) — meaning must live in the row content. A whole row becomes a link through Item's own render, not through the timeline.

  1. Sending…
  2. 4 bounced

    Marked as failed, not hidden

Day grouping

TimelineSeparator renders a computed group header through Marker's separator variant. The component imposes no grouping of its own — the host decides where a day boundary falls and emits a separator there.

  1. This week
  2. main@a1f7c2 deployed

    Production · 42s · triggered by Priya Sharma

  3. main@9c0e11 failed

    Build step exceeded the 10-minute limit

  4. Last week
  5. Ada rolled back to main@77b4de

    Reason: checkout regression on Safari

API Reference

PropTypeDefaultDescription
aria-labelstringAccessible name for the timeline list.

Data attributes and CSS variables on Timeline

AttributeValues
data-slot"timeline"

Item

PropTypeDefaultDescription
nodeReact.ReactNodeThe rail node for this entry — a StatusIcon, an Avatar, or any small glyph. Defaults to a neutral dot. Purely decorative: the entry's meaning must live in its content, so the node column is aria-hidden.

Data attributes and CSS variables on TimelineItem

AttributeValues
data-slot"timeline-connector" | "timeline-content" | "timeline-item" | "timeline-node" | "timeline-rail"

TimelineSeparator adds no props of its own — it accepts everything <li> accepts and renders its children through Marker variant="separator". Place separators between items, never as the final child (the last item's connector hides via :last-child).

Accessibility

  • Compose Item rows directly — an Item outside an ItemGroup carries no ARIA role, so the <li> stays the only list item and nothing nests invalidly. A render-composed interactive row keeps its native link/button role.
  • An <ol> of <li> entries, labelled via aria-label; group separators are real list items (an <ol> may only contain list items) rendered through Marker's separator variant — wrap their children in a heading element when the label should join the page outline.
  • The rail column (node + connector) is aria-hidden decorative geometry; an entry's status must also appear as text in its content, never by node colour alone. Superseded entries need a text signal, never strike-through alone.
  • Give each entry a <time dateTime>RelativeTime renders one, with its deliberate no-aria-live policy (a feed that re-announces every minute is noise). Leave the timestamp column at the row's own type size: RelativeTime is a focusable tooltip trigger, and its invisible hit area is sized against a 14px line box, so shrinking it to text-sm drops the effective target under the 24px floor.
ContractStates tested
Behaviourdefault
Accessibilitysemantic-html, labeled
Visualdefault

Do / Don't

Do
Compose Item parts inside TimelineItem and pass RelativeTime a fixed `now` for deterministic SSR.
Don't
Reach for MessageScroller for a static chronological record — the live-feed primitive is dead weight without prepend-on-scroll behaviour.

On this page