Timeline Swimlane
Events across parallel resource lanes on one shared time axis, with overlap stacking, hour/day/week zoom, a caller-supplied now marker and four data states.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/timeline-swimlane.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "TimelineSwimlane" component with zod and
lucide-react. No animation library, no charting library, no measurement: the
layout is pure arithmetic expressed in percentages.
Contract
- A zod schema in a sibling contract file is the single source of truth and the
props are z.infer of it, never a parallel interface:
lane = { id, label, meta?, accent?: 1..5 }
item = { id, laneId, label, start, end?, tone?: "default"|"muted"|"critical", detail? }
data = { status: "loading"|"empty"|"error"|"ready", lanes: lane[], items: item[], now? }
- start / end / now are ISO 8601 instants ("2026-03-04T08:00:00Z"). A datetime
with no zone designator is read as UTC, never as local time — a value that
depends on the renderer's zone gives the server and the client two different
layouts. `end` is exclusive; omit it for a point event.
- Component props = the inferred data type + Omit<HTMLAttributes<HTMLDivElement>,
keyof data> + zoom?: "hour"|"day"|"week", defaultZoom?: zoom | "auto"
(default "auto"), onZoomChange?, selectedId?: string | null,
defaultSelectedId?, onItemSelect?: (item | null) => void, onRetry?,
emptyState?: ReactNode, laneWidth?: number (px, default 168, clamped 96–320),
skeletonLanes?: number (default 4, clamped 1–12), locale? (default "en-US"),
timeZone? (default "UTC"), label? (default "Timeline"), className.
- forwardRef to the root <div>; spread the remaining native props on it.
- Zoom and selection are each controlled-or-uncontrolled: pass the value plus
its callback to own it, pass only the default* prop to let the component keep
it. Clicking the selected item again clears the selection and reports null.
Behavior — the maths
- Parse: Date.parse on a normalised string, per item. An unreadable `start`
makes the item unplaceable: it is COUNTED IN THE FOOTER, never silently
dropped. An `end` that is missing, unreadable or not after the start makes the
item a point event (an instant) rather than a negative-width bar.
- Window: [min start, max end] over the placeable items, snapped OUTWARD to the
zoom's step in the target time zone — whole hour, whole day, or whole Monday.
Monday snapping is integer arithmetic, not a Date round trip: epoch 0 is a
Thursday, so floor((t + 3 days) / week) * week - 3 days lands on Monday.
The zone offset is sampled once at the window start with
Intl.DateTimeFormat(...).formatToParts and folded into that anchor, so a DST
jump inside the window shifts boundaries by an hour instead of throwing.
- Degenerate window: if every item sits on one instant the snapped window is
zero wide and every percentage below would divide by zero — open it to
exactly one step.
- Auto zoom: span <= 36h -> hour, <= 45 days -> day, else week. It only decides
the DEFAULT; the window always covers every item at every zoom.
- Track width = (span / step) * px-per-step (88 / 72 / 84), clamped to
280–20000px, and the scrollport scrolls horizontally when it exceeds the
host. Zoom therefore changes density, never which items are visible.
- Stacking: ms-per-px = span / track width, so a bar's minimum pixel width (10)
and a diamond's hit box (16) can be expressed back in ms. Sort items by start
ascending and greedily place each one on the first sub-row whose last
footprint ended at or before this footprint starts — ascending order is what
makes first fit optimal. Lane height = 2 * padding + levels * item height +
(levels - 1) * gap, so a lane grows only as deep as its worst overlap.
- Position: left = (start - window start) / span * 100, width = the same for the
end, floored at the minimum width; a minimum-width bar on the right edge is
pulled back to 100 - width so it cannot poke past the track. Points are
centred with translateX(-50%), clamped to 0 / -100% at the two edges.
- Ticks: one per step, strided down when the count would exceed 240, and a label
every Nth tick where N = ceil(52px / tick width) so labels never collide.
- `now` renders a chip and a line only when it falls inside the window; the
window is derived from the items and is never stretched to reach a stale
clock. The component never calls Date.now(), so server and client agree.
Behavior — interaction
- Four first-class branches on status: loading -> skeleton lanes at the SAME
pinned-column width and lane height as the real chart (no jump when data
lands); empty -> icon + text, replaceable via emptyState; error -> message
plus "Try again" only when onRetry exists; ready -> the chart. `ready` with
nothing placeable shows a short note instead of an empty grid.
- Retry is one-shot: a ref is read AND written synchronously inside the handler,
so a double click cannot fire two requests (a state-only guard is one render
behind and would let the second through). It is re-armed when status leaves
"error" — which is exactly what a real retry does on its way to loading.
The button goes aria-disabled, NEVER native disabled: the browser blurs a
control the instant it is disabled and would drop the reader on <body>.
- Every item is a real <button>. Keyboard: ArrowLeft / ArrowRight step along the
lane in start order, ArrowDown / ArrowUp jump to the temporally NEAREST item
on the next non-empty lane (skipping lanes with nothing booked), Home / End go
to the first / last item of the lane, Enter or Space selects, Escape clears
the selection. Every handled key calls preventDefault so the scrollport does
not scroll out from under the focus.
- Roving tabindex: exactly one item is tabbable — the selected one, or the
earliest item — so Tab enters and leaves the whole chart in one stop.
- The zoom switcher is its own role="radiogroup" with role="radio" +
aria-checked children, roving tabindex, and Arrow / Home / End selecting
immediately (selection follows focus).
- A detail line under the chart reads out the hovered OR focused item, falling
back to the selected one. It is deliberately NOT a live region: it mirrors
hover, and the focused button already announces itself.
Rendering & styling
- Semantic tokens only. Lane colour is var(--chart-1..5) chosen by lane.accent
or by lane index, used through color-mix(in oklab, var(--chart-N) 18%,
transparent) for the bar fill and 45% for its border, so one hue works in both
themes. tone="muted" -> dashed border + bg-muted + text-muted-foreground;
tone="critical" -> border-destructive/50 + bg-destructive/10. Surface bg-card,
grid border, now marker bg-primary, empty/error copy text-muted-foreground and
text-destructive. Merge the consumer className with cn().
- The lane column is `sticky left-0` inside the single overflow-x-auto
scrollport and must be OPAQUE bg-card, otherwise the scrolling bars show
through it; the grid, the now line and the ticks live in one aria-hidden
overlay positioned from that column's width.
- ARIA: the root is role="group" with an aria-label; lanes are role="list" /
role="listitem"; the axis, grid, now line and the diamond glyphs are
aria-hidden decoration, and every fact they encode is in an item's aria-label
("Studio A: Morning show, Mar 4, 08:00 to Mar 4, 10:30, 2h 30m. Live desk").
A bar narrower than 54px drops its visible text — the accessible name and the
detail line still carry it.
- Motion: only decoration animates (the skeleton pulse, hover shadow) and all of
it carries motion-reduce:*. The "Jump to now" scroll uses smooth behaviour
only when prefers-reduced-motion is not set, read through
useSyncExternalStore over matchMedia whose listener is removed on unmount.
Nothing else subscribes, observes or times anything.
Customization levers
- Density: ITEM_HEIGHT / ITEM_GAP / LANE_PADDING are three constants — 16/2/4
makes a compact utilisation board, 28/6/8 a touch-friendly one. PX_PER_STEP
sets how wide each zoom draws; raise the week value to make a quarter-long
plan scroll instead of compress.
- laneWidth is the responsive lever: ~168px suits a desktop panel, pass ~110 in
a narrow host so the track keeps most of the width.
- Sub-blocks are independent: omit `now` to drop the marker lane and the jump
button, fix `zoom` to remove the switcher, drop the detail line if your rows
already have a side panel.
- Domain: swap the item schema's fields for shifts, reservations, flights or
pod restarts and keep { id, laneId, start } as the anchor.
- Encoding: `tone` is the extension point — add "success" as one more class
branch rather than a second bar, and keep the lane accent for identity.
- Zones: timeZone changes labels AND snapping together; hand it the viewer's
IANA zone (Intl.DateTimeFormat().resolvedOptions().timeZone) from a client
effect if you want local boundaries, and keep UTC for reproducible snapshots.Concepts
- Shared axis, parallel lanes — every lane is measured against one window, so two bars at the same x really are simultaneous. That is the whole reason to reach for a swimlane instead of one timeline per resource: comparison is horizontal, not mental.
- Overlap becomes depth — a double booking is not hidden, moved or averaged away; the lane grows a sub-row and both items stay readable. Greedy first fit over start-sorted items is optimal here, and the lane's height is exactly its worst overlap.
- Intervals and instants are different animals — a bar answers "how long", a diamond answers "when". Because a diamond has no duration, its collision footprint is the pixel box it occupies, converted back into milliseconds — which is also how a one-second bar stays clickable instead of collapsing to a hairline.
- Zoom is density, not filtering — hour / day / week change how many pixels a step gets and which ticks are drawn; the window still covers every item, so zooming can never make an event disappear behind a scroll edge you did not know existed.
- The clock is an input —
nowis a prop and instants are laid out in percentages, so the component never reads the system clock: server and client render identical markup and screenshots are reproducible. A live marker is the caller's refresh loop, not a hidden timer. - Decoration above, meaning in text — bars, diamonds, grid and the now line are
aria-hidden; each item is a button whose accessible name states lane, label, full range, duration and detail, so a narrow bar that drops its visible text loses nothing for a screen reader.
Calendar Month
A month grid of events — multi-day bars packed into shared lanes, a "+N more" day peek, today and out-of-month cells distinguished, configurable week start and four data states.
Dashboard Grid
A draggable, resizable widget grid: widgets snap to a column grid, collisions push neighbours down deterministically, and every accepted change emits the whole layout for the consumer to persist.