Display

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { AlertCircle, CalendarClock, Crosshair } from "lucide-react"

import { cn } from "@/lib/utils"
import type {
  TimelineSwimlaneData,
  TimelineSwimlaneItem,
  TimelineSwimlaneLane,
  TimelineZoom,
} from "./timeline-swimlane.contract"

const HOUR_MS = 3_600_000

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/timeline-swimlane.json

Prompt

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 inputnow is 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.

On This Page