Timeline
Rail geometry for chronological records — a continuous connector with a node per entry; rows are Item parts, timestamps are RelativeTime.
- Status
- Since
0.4.0- Accessibility pattern
- ordered list of items
Last updated
Install
Add Timeline from the VegaStack registry. The CLI verifies the item's integrity hash before writing it.
pnpm dlx shadcn@latest add @vegastack/timelineThe 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
| Behaviour | Where it lives |
|---|---|
| Row anatomy (title, media…) | Item parts — compose them as children |
| Day grouping + midnight roll | Host data work; TimelineSeparator renders the computed header |
| Live bottom-pinned feeds | MessageScroller — a different job |
| Virtualization | Not 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):
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 anatomyStatus 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.
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.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
aria-label | string | — | Accessible name for the timeline list. |
Data attributes and CSS variables on Timeline
| Attribute | Values |
|---|---|
data-slot | "timeline" |
Item
| Prop | Type | Default | Description |
|---|---|---|---|
node | React.ReactNode | — | The 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
| Attribute | Values |
|---|---|
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
Itemrows directly — anItemoutside anItemGroupcarries no ARIA role, so the<li>stays the only list item and nothing nests invalidly. Arender-composed interactive row keeps its nativelink/buttonrole. - An
<ol>of<li>entries, labelled viaaria-label; group separators are real list items (an<ol>may only contain list items) rendered throughMarker'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-hiddendecorative 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>—RelativeTimerenders one, with its deliberate no-aria-livepolicy (a feed that re-announces every minute is noise). Leave the timestamp column at the row's own type size:RelativeTimeis a focusable tooltip trigger, and its invisible hit area is sized against a 14px line box, so shrinking it totext-smdrops the effective target under the 24px floor.
| Contract | States tested |
|---|---|
| Behaviour | default |
| Accessibility | semantic-html, labeled |
| Visual | default |
Do / Don't
Settings Row
A borders-only settings layout — titled sections, bordered cards, and label-plus-control rows for building account, workspace, and preference screens.
Sortable List
Reorderable rows with pointer drag, keyboard move mode, a lossless row menu, and server-refusable moves — controlled; the host owns the order.