Charts
Multi-Series Line Chart
A four-state multi-series line chart whose shared crosshair reads every series at the hovered x, ranked by value, with null gaps left broken.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-line-multi.jsonPrompt
Build a React + TypeScript + Tailwind "ChartLineMulti" dashboard card on the
shadcn chart primitives (ChartContainer/ChartTooltip over recharts) with zod.
Contract
- A zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
unit?: string;
series: { key, label, points: { x: string, value: number | null }[] }[] }.
- At most 5 series — one per chart token. value === null means "no reading",
never zero. x is a label rendered as-is, so callers keep control of
granularity (hours, ISO days, week numbers).
- The schema refines that every series carries the same x sequence point for
point: a shared crosshair reads one row per x, so a series with nothing to
report at some x carries null there instead of a shorter array.
- Component props = z.infer of the schema, plus onRetry?: () => void and
className. No hand-written parallel interface.
Behavior
- Pivot the series-major contract into row-major rows ({ x, [key]: value })
in a memo; plot one <Line dataKey={key}> per series over those rows.
- Gaps: connectNulls={false}, so a null splits the path into separate
subpaths rather than drawing a straight line across missing readings, and
no active dot appears at a missing point.
- Shared crosshair tooltip: hovering anywhere in the plot lists every series
at that x in one panel, ranked by value descending, with missing readings
sunk to the bottom as "—". The panel reads the whole pivoted row from
payload[0].payload, so a null series is still listed instead of quietly
dropping out of the tooltip payload.
- Highlight: legend chips are real <button type="button">. Hover or focus
highlights that series (the rest drop to strokeOpacity 0.25, the chosen one
thickens); click pins it (aria-pressed), clicking again unpins. The
effective key is hovered ?? pinned, re-derived against the current series
every render, so a key left over from a previous payload simply stops
matching instead of dimming the whole chart.
- Y domain is fitted to the data and rounded outward to a 1/2/5x10^n step, so
crossings stay legible and ticks stay round; an all-equal series gets a +/-1
window instead of a zero-height domain.
- The four states are first-class branches inside one bg-card panel:
- loading: skeleton title + chips over a pulsing two-polyline silhouette at
the same plot height, aria-hidden.
- empty: outline polyline + "No data yet" + one explanatory line; a ready
payload with nothing plottable falls into this branch too.
- error: message + a "Try again" button rendered only when onRetry exists.
- ready: header + legend + chart + sr-only data table.
Rendering & styling
- Colors come only from the chart tokens: series i uses
var(--chart-{(i % 5) + 1}) for its Line stroke, its active dot, its legend
swatch and its tooltip swatch — one formula, four consumers.
- Series identity carries a second, color-independent channel: index maps to a
dash pattern (solid, "6 4", "2 5", "12 5 2 5", "1 5") echoed in the legend
and tooltip swatches, so the chart still reads when the host palette is a
monochrome ramp or unsafe for color-blind readers — five thin strokes are
the worst case for telling neighbouring hues apart.
- Accessibility: the plot area is role="img" with a generated aria-label
(title, series count, span, each series' first-to-last value and how many
readings are missing). recharts' accessibilityLayer is switched off so no
focusable role="application" svg sits inside that image subtree, and the
exact numbers live in a sibling data table hidden by an sr-only *wrapper
div* — a bare sr-only table keeps auto table layout, ignores width:1px and
pushes ~300px of horizontal document overflow on a 375px screen.
- Animation: the line draw-in runs unless prefers-reduced-motion is set, read
through useSyncExternalStore over matchMedia with a false server snapshot.
- Axes: XAxis on the x key, tickLine/axisLine off, minTickGap ~24 so labels
thin out instead of overlapping at 375px; YAxis width 40 with compact
notation only at >= 10,000 (below that "1,050" and "1,100" would both
collapse to "1.1K").
- Panel: rounded-xl border bg-card; values are tabular-nums; cn() merges
className.
Customization levers
- Series count: 1–5. A single series degrades to a plain trend line with the
readout intact; past 5 the token cycle repeats, so split the chart or make
the extra series toggleable rather than inventing a sixth color.
- Ranking: the tooltip sorts by value descending. Swap the comparator for
contract order when the series have a fixed reading order (p50/p90/p99).
- Baseline: the fitted y domain is the line-chart default; pass [0, "auto"]
when absolute magnitude matters more than the shape of the crossings.
- Density: a ~260px plot with p-6 suits a dashboard grid; ~180px with p-4 for
a compact card, and drop CartesianGrid for a sparkline feel.
- Curve: type="monotone" smooths between readings; use "linear" when every
point is a real measurement and nothing should be implied in between.
- Palette: replace the color formula to pin a fixed token per key (brand vs
competitor); keep the dash channel if the host palette is monochrome.Concepts
- Shared crosshair — the readout is keyed to the x position, not to the nearest line: one hover answers "what is every series doing at this moment", which is the whole reason to stack series on one axis instead of small multiples.
- Ranked readout — rows are ordered by value at that x, so the panel itself shows who is on top; the order changes as you sweep across crossings, which is exactly the information a legend-ordered tooltip hides.
- Null is a gap, not a zero — a missing reading breaks the stroke and is listed as
—in the readout. Interpolating across it (connectNulls) would invent a trend the source never reported; charting a null as 0 would invent an outage. - Highlight vs pin — hover and focus are transient highlights, a click pins one series until it is clicked again; the effective series is
hovered ?? pinned, so pointer and keyboard reach the same state without a second control. - Redundant encoding — every series owns both a chart token and a dash pattern, and both appear in its legend swatch, so identity survives a monochrome palette, a color-blind reader and a grayscale print.
- Graphic plus data table — the plot is one labelled
role="img"graphic (no focusable nodes inside it), and the numbers are repeated in a visually hidden table, so assistive tech gets the summary and the exact values instead of a pile of<path>elements.
Area Chart (Stacked)
A four-state stacked area chart card whose legend chips toggle single series, re-stacking the survivors and rescaling the y-axis, with a per-series plus total tooltip.
Radial Progress Chart
Concentric progress rings — one ring per metric against its own target, a summary number in the hole, and over-target values capped at one lap instead of drawing through.