Charts

Event Timeline

A four-state timeline of instants — one lane per track, a shape per kind, marks that crowd into one counted cluster instead of overlapping, brush-or-keyboard zoom into any window, and a now marker from an injected instant.

Preview in your theme

Loading preview…

"use client"

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

import { cn } from "@/lib/utils"
import { useResizeObserver } from "@/registry/hooks/use-resize-observer"
import {
  buildEventTimelineLayout,
  type ChartEventTimelineData,
  type ChartEventTimelineKind,
  type EventTimelineLane,
  type EventTimelineMark,
  type EventTimelineMember,

Installation

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

Prompt

Build a React + TypeScript + Tailwind "ChartEventTimeline" card in plain SVG
with zod. Recharts has no primitive for this — the marks are zero-width, the
crowding rule is a clustering pass and the zoom is a window over the domain
rather than a data transform — so the layout, the axis and the label fitting are
small pure functions living beside the schema. One ResizeObserver, no other
dependency beyond lucide icons.

Contract
- One zod schema is the source of truth:
  { status: "loading" | "empty" | "error" | "ready"; title: string;
    description?: string; instant?: string;
    lanes?: { id: string; label: string }[];
    events: { id: string; label: string; at: string; lane?: string;
              kind?: "info" | "success" | "warning" | "critical";
              detail?: string }[] }.
  `at` is ONE INSTANT, never a pair: an event has a position, not an extent. A
  component that grows an `end` has become a gantt chart and should be one.
- `at` is ISO 8601, parsed with integer calendar arithmetic (Hinnant's
  days_from_civil), never `new Date(string)`: a datetime with no offset is read
  as UTC so server and client land on the same pixel, and an impossible civil
  date (2026-02-30) is rejected by round-tripping rather than silently rolled
  into March. Timestamps and duplicate ids are deliberately NOT refined in the
  schema — a malformed row is a fact about the feed, and the chart's job is to
  draw the rest and say how many it could not place.
- superRefine: a ready timeline needs at least one event; declared lane ids
  unique. Guard every access — sibling refinements all run, so a ragged payload
  must produce an issue rather than a TypeError thrown out of safeParse.
- `instant` is the "now" marker and it is INJECTED. The component never calls
  Date.now(): a chart that reads the clock while rendering draws a different
  picture on the server and in the browser and can never be snapshot-tested.
- Props = z.infer of the schema plus variant ("dot" | "flag", default "dot"),
  density ("comfortable" | "compact"), clusterDistance (px, default 14, clamped
  0-64), labelRows (default 3, clamped 1-5), range, padding (default 0.04),
  locale (default "en-US"), unassignedLabel, onEventSelect, onWindowChange,
  onRetry, emptyState, className and the div's native props, forwardRef to the
  card.
- Export the maths beside the schema so it is testable and so this prompt can
  describe it: parseInstant(), buildTimeTicks(), clusterInstants(),
  stackLabelRows(), buildEventTimelineLayout(), markPath().

Behavior
- LAYOUT IS FRACTION SPACE. buildEventTimelineLayout returns every mark's x in
  0-1 of the drawn window, so the same numbers are right at any pixel width and
  a test can print them without rendering. The component multiplies by the
  measured plot width and adds the lane gutter; nothing else knows about pixels.
- CLUSTERING is the density rule, and it is a question about the picture, not
  the data: the component turns clusterDistance px into a fraction of the
  window, and clusterInstants folds a lane's ascending instants into ranges.
  Each cluster is anchored on its own FIRST instant — a member joins while
  at - first <= gap — never chained off the previous member: chaining lets a
  steady drip of events one pixel apart swallow the whole axis into one mark.
  Anchoring bounds a cluster to `gap`, so the glyph is never more than gap/2
  from any event behind it. The glyph sits at the midpoint of its members, wears
  the loudest kind among them (info < success < warning < critical) and carries
  a ring plus a count. Zooming shrinks the gap in data terms, so clusters come
  apart by themselves — no state, no expand/collapse.
- ZOOM. Dragging across the plot brushes a window; past a 6px slop it takes
  pointer capture (never on pointerdown — capturing early retargets the click
  away from whatever was pressed), draws the window with its duration on top,
  and commits on release. A press that never travelled is a click, and a click
  is never a zoom. THE GESTURE IS NEVER THE ONLY PATH: `+` and `-` zoom by 2x
  around the keyboard cursor, `0` and Escape restore the full window, Enter on a
  cluster zooms into exactly that cluster, and two real buttons (Zoom out, Show
  everything) do the same with a pointer. The floor is 1/2048 of the full
  window, past which every axis label reads the same instant; a brush thinner
  than that is ignored and a keyboard zoom is clamped to it. The window is state
  in the component and also emitted through onWindowChange for linked views.
- The zoom window is DERIVED, never trusted: it is clamped into the full domain
  on every render, so a feed that reloads over a narrower span can never strand
  the view somewhere its own data no longer reaches.
- THE KEYBOARD CURSOR IS AN INSTANT PLUS A LANE, not an index. Zooming
  re-clusters the marks, so an index would point at a different event after
  every zoom; an instant lands on whatever mark now stands closest to where the
  reader was, in the lane they were reading, and falls back to the whole chart
  only when that lane has emptied.
- The axis fits the events plus 4% at each end, or takes a fixed `range`, and
  always includes the injected instant — a window that excluded "now" would
  answer "has anything happened since?" by hiding the only mark that says. One
  instant, or a hundred recorded at the same instant, opens half an hour either
  side instead of dividing by zero. Ticks land on CALENDAR boundaries (seconds,
  minutes, hours, midnights, Mondays, 1sts, years), one per ~104px, choosing the
  rung whose count is closest to the target IN LOG SPACE — "first rung above
  ideal" lands up to 3x too sparse on a ladder that jumps 2-3x per rung.
- FLAG LABELS (variant="flag"): each mark's name goes on a leader line, flipped
  to the left only when it would leave the plot, and packed by first-fit into
  the lowest free row per lane (stackLabelRows, boxes sorted by left edge, which
  is what makes first-fit optimal for intervals). A lane is exactly as tall as
  the rows its own names needed. A label that fits no row within labelRows is
  DROPPED AND COUNTED on the card — the mark stays, the name is in the readout
  and in the table.
- NOTHING IS DROPPED IN SILENCE: rows whose timestamp cannot be placed, rows
  repeating an id, and rows outside the window are counted and printed. While
  zoomed the header reads "14 of 38 events" instead, so the same fact is never
  stated twice.
- Four first-class branches of one card: loading (deterministic skeleton lanes,
  aria-hidden, plus one sr-only role=status line), empty (a valid contract with
  nothing placeable, which also states how many rows were skipped, because an
  empty chart over a feed full of malformed timestamps is the one case where "no
  data" is a lie), error (a Try again button only when onRetry was passed; a
  broken `range` renders here with the reason), ready.
- CLEANUP: one ResizeObserver, disconnected on unmount and on node swap. No
  timers, no rAF, no window listeners — the drag runs on pointer capture, and
  the one-shot commit is a ref read and cleared in the same tick, because
  pointerup and lostpointercapture both arrive for one gesture and a second
  commit would zoom twice.

Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground for the panel, border for
  gridlines, lane rules and leader lines, muted for alternating lane bands and
  the skeleton, muted-foreground for axis and gutter text, ring for the focus
  outline, foreground for the now line and the keyboard cursor,
  var(--chart-1/2/4) for info / success / warning and var(--destructive) for
  critical — severity is not a series, and the one kind that means "this broke"
  wears the token the rest of the product uses for it.
- COLOUR IS NEVER THE ONLY CARRIER. Each kind has its own outline (circle,
  diamond, triangle, saltire), is named with its count in the legend, is said in
  the readout and is spelled out in the sr-only table. The triangle is spent on
  `warning` because that is the shape every reader already knows as a warning
  sign, and the cross on `critical` because a saltire survives being 9px wide
  better than an outline with an interior.
- FILL CARRIES TIME, not kind: a mark after the injected instant is hollow
  ("scheduled, not yet true"), one before it is filled. Shape and fill are
  orthogonal, and the legend says so.
- Text: axis and gutter at 11px (10px compact) in muted-foreground; flag labels
  and the now label use paint-order:stroke with a --card stroke as an SVG halo,
  so a name stays readable over a lane band or a gridline. Long lane names and
  long labels elide with an ellipsis and keep the full text in a <title> and in
  the table — never overflow.
- ACCESSIBILITY: the plot is role="listbox" + aria-orientation="horizontal" with
  ONE tab stop and aria-activedescendant, not role="img" (children-presentational
  would silence every option) and not a tabindex per mark (200 events would be
  200 stops). Each mark is a role="option" whose accessible name is a full
  sentence: label, lane, timestamp, kind, detail, and for a cluster "5 events
  within 4 minutes of …, loudest critical, A, B, C and 2 more, press Enter to
  zoom into them". A polite sr-only region announces the window whenever it
  changes, because a zoom changes what the card claims and a gesture is
  invisible to a screen reader. Below the plot an sr-only WRAPPER DIV (never
  sr-only on the table itself: CSS width is only a lower bound for a table box,
  so a narrow viewport picks up real horizontal scroll) holds one row per event
  — including every member of every cluster, so nothing is reachable only by
  zooming.
- Buttons are never natively disabled: Zoom out and Show everything stay in the
  tab order with aria-disabled and a handler guard, because a control that
  vanishes the moment it becomes irrelevant takes the reader's place in the page
  with it.
- Motion: the only animation is the loading skeleton's pulse plus a 200ms
  opacity fade on the marks, both carrying motion-reduce. Nothing about the
  chart depends on either.

Customization levers
- variant: "dot" for a feed of hundreds, "flag" for a dozen milestones that
  should each wear a name. labelRows trades vertical space for names; 1 keeps a
  strip flat, 5 lets a busy quarter show everything.
- density: "compact" (22px lanes) fits a sidebar, "comfortable" (30px) a
  dashboard card. It changes rhythm only — no geometry, no counts.
- clusterDistance: raise it to summarise aggressively on a small card, drop it
  to 4-6 to keep near-simultaneous events apart, set 0 to turn it off — except
  for events on exactly the same instant, which still share a glyph, because two
  marks at one x cannot be told apart at any zoom. Whatever it is, the
  disclosure follows: there is no configuration in which an event disappears
  without the card saying so.
- range / padding: pin a window to compare two cards side by side, and remember
  that a pinned window means rows outside it are counted out loud rather than
  quietly missing.
- Kinds and palette: the four-kind set is deliberately closed because each kind
  owns a shape. Re-point KIND_INK for a different palette, or key the ink off a
  domain status ("deploy" / "incident") — but keep one outline per kind, or
  colour becomes the only carrier again.
- Interaction: onEventSelect carries the whole event (id, label, instant ms,
  kind, detail) — wire it to a drill-down, a trace link or a linked table.
  onWindowChange is the same window an axis brush would emit, so the chart can
  drive linked views instead of only zooming itself.
- Time formatting: locale is a prop and every Intl formatter is built in UTC, so
  a card is reproducible; swap the timeZone if the feed is local and you accept
  that a screenshot then depends on where it was taken.

Concepts

  • Instant, not interval — every mark has a position and no extent, so the only thing the horizontal axis encodes is elapsed time between events. That is the whole difference from a gantt bar or a swimlane block: there is nothing to stack, nothing to pack into sub-rows, and a quiet lane is quiet because nothing happened, not because its bars were short.
  • Density clustering — when two events land closer than clusterDistance px they become one glyph with a ring and a count, anchored on the first of them so a cluster can never grow wider than the gap it was allowed. It is derived from pixels, which is why zooming in dissolves clusters without touching the feed, and why the same data reads differently on a phone and on a dashboard — honestly, in both cases.
  • Brush as zoom, keys as brush — dragging across the plot picks a window and the chart redraws inside it; +, , 0, Escape, Enter-on-a-cluster and two ordinary buttons do the same thing without a pointer, so the gesture is a shortcut rather than the only door. Every path announces the new window in a polite live region, because a zoom silently changes what the card claims.
  • Injected instant — "now" arrives as a prop, never from the clock. That is what makes the picture reproducible across server, browser and screenshot; it also lets a postmortem set "now" to the moment the page was paged, and it is the line that decides which marks are drawn hollow because they have not happened yet.
  • Shape carries the kind, fill carries time — four kinds, four outlines, each named with its count in the legend and spelled out in the table, so a colour-blind reader, a greyscale print and a projector all still see four different things. Fill is kept for a separate question — before or after the instant — so the two encodings never collide.
  • Counted, never swallowed — a timestamp that will not parse, an id that repeats, a row outside a pinned window, a flag label with nowhere to go: each is dropped from the picture and counted on the card. The rule is that the number of marks on screen always matches a number the card states, so the one thing a timeline must never do — quietly lose an event — cannot happen by configuration.

On This Page