Timeline
A sequence of markers joined by a connector. Content sits beside its marker; meaning stays with the consumer.
A layout, not a state model
The timeline places markers and joins them. It has no status, no active, and no item states at all, because what an item means — a city, a flight, a day, done or still ahead — is a claim about the app's domain that a registry primitive cannot make. A route rail is a composition: you pick the markers, you paint the current one, and the timeline keeps them in line. For a position in a sequence of known count with no content beside it, reach for stepper instead.
Parts
Timeline renders an ol with list styling reset. TimelineItem renders an li. The connector to the next item is drawn for you — it belongs to the item, the marker paints it because that is where the geometry lives, and there is none after the last item or on an item with no marker. TimelineMarker is a slot: empty it renders a neutral dot, and with children it renders a bordered circle around them. TimelineContent is free-form, with TimelineTitle and TimelineDescription mirroring card's typography. Every part carries a data-slot.
- Two nights in the Old Quarter
- Train to Ninh Binh2h 20m from Ha Noi station
- Ninh BinhOne night, boats through Tam Coc in the morning
- Flight to Da Nang1h 20m, lands at midday
- Hoi AnThree nights on An Bang beach
- Two nights in the Old Quarter
- Train to Ninh Binh2h 20m from Ha Noi station
- Ninh BinhOne night, boats through Tam Coc in the morning
- Flight to Da Nang1h 20m, lands at midday
- Hoi AnThree nights on An Bang beach
Orientation
Vertical is the default: the marker column sits on the left with the marker centred on the title's first line, and the content sits beside it. Horizontal puts the content below the marker and gives every item flex-1, so markers stay evenly spaced however long the content runs. The value is exposed as data-orientation for consumers styling around it.
The timeline is host-agnostic: it never reads the sidebar context. A rail that has to follow the shell takes its orientation from the consumer, which reads useSidebar().layout and passes the matching TimelineOrientation down.
Strip
A strip is a horizontal timeline whose items omit TimelineContent. There is no hidden-content mode and no separate component: leaving the content out is the whole recipe.
Marker sizes
Default is a 24px box holding a 16px icon, or a 12px dot when it is empty. Small is a 16px box holding a 12px icon, or an 8px dot. Both centre on the same 24px line, so a rail can mix them: the rail above gives its cities default markers and its transport anchors small ones, and the connector still runs straight.
Mixing sizes is a vertical affordance. A horizontal connector spans from its own marker's edge using its own size, so a horizontal timeline should keep one size throughout or the line will overshoot at one end.
Spacing
The gap between items comes from --timeline-spacing on the root, --spacing(6) by default. Override it on one timeline and both the gaps and the connectors that bridge them follow. The gap between a marker and its content is a fixed --spacing(3). There is no density prop: one variable does the job a prop would.
A moving current marker
The component owns no animation. When an app wants the current marker to travel rather than blink between positions, it renders a motion.span with a shared layoutId inside the current item's marker and animates it on springBounce — the same mechanism as the sidebar's active bar. Wrap the timeline in a LayoutGroup with a useId so two instances on one page do not fly into each other.
A shared layoutId animates only when the element it leaves and the element it enters are mounted in the same commit, so the timeline has to survive whatever changes the current item. Mount it in the shell — a sidebar or a persistent header — not inside the route that unmounts on navigation. If the host remounts, the marker appears at its new place with no travel, which is the correct fallback, not a bug.
Contrast
The empty marker's dot is --muted-foreground on --background: 5.2:1 in light mode and 7.0:1 in dark, against the 3:1 a non-text graphic needs. An icon marker draws a 1px --border circle and puts the icon in --foreground at 18.3:1 light and 17.5:1 dark, so the icon carries the 3:1 and the circle carries nothing. The connector is 2px of --border, 1.3:1 in both modes: it is decorative and exempt under WCAG 1.4.11, because it says only what the markers and the content already say.
The title runs --foreground at 18.3:1 light and 17.5:1 dark; the description runs --muted-foreground at 5.2:1 light and 7.0:1 dark. A link inside the title stays --foreground in every state and carries a permanent underline: 1px --muted-foreground at rest, stepping to 1.5px --foreground on hover and press, 4.73:1 light and 7.63:1 dark. Those are text link's values, so every prose link in the portfolio follows one rule.
Accessibility and motion
A screen reader reads an ordered list: the root is an ol and every item is an li, so the order and the count are announced without any extra ARIA. The connector is aria-hidden. Icons you put in a marker should be decorative too, because the title beside the marker already names the stop.
Tab reaches one stop per linked title, in document order. The title paints the underline, the colour, and the hover, but the anchor is yours, so you put offsetFocusRingGeometry from the interaction lib on it — that keeps the ring in the one place ADR 0006 puts it and keeps the timeline itself dependency-free. Nothing else in the timeline is focusable, and there is no stretched link: an item is never a hit target, so a route rail with five cities and one linked title costs the keyboard user exactly one stop.