Charts
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.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-area-stacked.jsonPrompt
Build a React + TypeScript + Tailwind "ChartAreaStacked" 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;
series: { key, label, points: { date: ISO string, value >= 0 }[] }[] }.
- Array order IS stack order: series[0] is the bottom band. Cap the array at 5
because that is the whole var(--chart-1..5) palette — a 6th series would need
a color the host theme does not define.
- Refine two invariants so bad feeds fail loudly instead of drawing a wrong
picture: "ready" needs at least one series, and every series must repeat the
same date sequence point for point (a stack of misaligned series is a lie).
- Props = z.infer of the schema plus onRetry?: () => void and className. No
hand-written parallel interface.
Behavior
- Pivot the per-series contract into recharts' one-row-per-date shape in a
useMemo; the shared-dates refinement is what makes the pivot safe.
- Hidden series live in a Set<string> of keys in state. A derived `visible` list
(series.map((s, index) => ({ ...s, colorIndex: index })).filter(not hidden))
feeds the header total, the tooltip, the aria summary and the sr-only table —
it carries the DECLARED index, never the post-filter index, so a series' color
is its identity and not its rank among whatever happens to be visible.
- The chart itself renders ALL declared series, always, in declared order, and
marks a hidden one with recharts' `hide` prop. Do not render only the visible
ones: recharts assigns stack slots in mount order, so unmounting a middle Area
and remounting it when the user turns it back on appends it to the TOP of the
stack. Measured on a 4-series stack (recharts 3.8): restoring series 2 moved
its cumulative from 282 to 386 and pushed series 3 and 4 down; across an
18-toggle sequence the unmount version put the stack in the wrong slots on 8
steps with a worst error of 233 units on a 600-unit axis, while the `hide`
version was correct on every step (worst deviation 2.5 units, i.e. rounding).
- `hide` still drops the band from the stack offsets AND from the y-axis domain,
so the axis rescales to the new maximum for free — never pin a fixed y domain
here or toggling stops being informative.
- Legend chips are real <button type="button"> with aria-pressed reflecting
visibility, wrapped in a role="group" with an aria-label; the toggle updater
is pure (clone the Set, add/delete, return it) so StrictMode's double call is
harmless.
- Everything toggled off is a real state, not a blank chart: replace the plot
with an "All series hidden" message plus a "Show all series" button, keeping
the same height so the card never jumps.
- Tooltip reads values off the hovered row object and walks the visible list in
reverse (top band first, matching the stack), then prints a Total row under a
divider. Reading the row instead of recharts' payload guarantees the listed
items and the total are exactly the rendered bands.
- Header carries the title, "N of M series shown", and the total of the visible
series — so the toggle has a numeric consequence, not just a visual one.
- The four states are first-class branches inside one bg-card panel:
- loading: skeleton header + skeleton legend chips + a fixed-literal column
silhouette, aria-hidden.
- empty: dashed band glyph + "No data yet" + one explanatory line.
- 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 + 1}) for
its legend swatch, its Area fill and its tooltip dot — one formula, three
consumers, so they can never drift apart.
- type="monotone", not "natural": a natural spline overshoots between points and
on a stack that reads as bands crossing each other. Monotone cannot overshoot,
so the bands stay nested.
- Bands are opaque (fillOpacity 1) and separated by a 1.5px stroke of
var(--card) — a hairline in the surface color, not in the series color. Two
measured reasons: a translucent fill blends with the card, so the same two
tokens separate differently per theme (adjacent-band contrast measured 1.20:1
light / 1.22:1 dark at 0.7 opacity vs 1.33:1 in both themes at opacity 1); and
a same-color stroke is invisible against its own neighbour on a monochrome
palette (1.03:1 measured in dark), while a card-colored hairline is guaranteed
to contrast with any band that contrasts with the card at all (worst boundary
measured 2.53:1 dark, 4.74:1 light). The tooltip's active dots inherit that
hairline, so they read as surface-colored markers on the band edges.
- XAxis on the date key with tickLine/axisLine off and minTickGap ~28 so ticks
thin out on narrow cards instead of overlapping; YAxis width 44 with a compact
Intl formatter ("1.2K") so the axis stays narrow. Ticks use Intl.DateTimeFormat
with an explicit "en-US" locale, and date-only ISO strings parse as local
midnight so ticks never shift a day.
- Accessibility: role="img" plus an aria-label summarising title, how many series
are shown, the date range and the visible total goes on the chart container
only (role="img" makes descendants presentational, so it must never wrap the
legend buttons). A screen-reader table repeats date / per-series value / total.
Put the sr-only class on a wrapping div, not on the <table>: overflow:hidden
does not reliably clip a table box, and a bare sr-only table keeps its
intrinsic width — measured at a 375px viewport it pushed
document.scrollWidth to 691px, i.e. it silently made the whole page scroll
sideways on mobile.
- Animation: isAnimationActive is driven by a prefers-reduced-motion media query
read through useSyncExternalStore with a false server snapshot — never read
matchMedia during render.
- Panel: rounded-xl border bg-card, border-b under the header, tabular-nums on
every figure so toggling doesn't jitter; cn() merges className.
Customization levers
- Stack order: it is purely the array order, so let the caller sort (largest at
the bottom, "Other" on top) before passing series — no prop needed.
- Palette: seriesColor(i) maps declared index to var(--chart-N). Swap it for a
per-key map (brand vs competitor) and both the bands and the legend follow.
- Density: h-[260px] and px-6 suit a dashboard grid; h-[180px] + px-4 for a
compact card, or drop the YAxis and CartesianGrid for a sparkline feel.
- Emphasis: on a colorful chart palette the bands can drop to fillOpacity ~0.5
with the stroke switched to the series color for the classic translucent area
look; keep the opaque + card-hairline default on a monochrome palette, where
it is what makes adjacent bands separable.
- Header: the "Total shown" tile can become an average, a delta vs a previous
period, or be dropped entirely; the legend row works standalone.
- Toggling: swap the Set for a controlled hiddenKeys/onHiddenKeysChange pair when
visibility must persist across reloads or sync with another chart.
- Granularity: points are date-keyed, so weekly or monthly ISO dates work as-is —
just widen the tick formatter (e.g. "Jun 2025").Concepts
- Declared-index color slots — a series' color is
var(--chart-N)of its declared position, never of its rank among the visible ones, so hiding a middle band cannot shift every survivor one color up. - Hide, don't unmount — recharts hands out stack slots in mount order, so removing a hidden Area from the tree and putting it back on re-show lands it on top of the stack; flagging it
hidekeeps its slot while still dropping it from the stack offsets and the y-axis domain. Measured on this component: across an 18-toggle sequence the unmount variant seated bands in the wrong slot on 8 steps (worst error 233 of a 600-unit axis), thehidevariant on none (worst deviation 2.5 units). - Axis rescale as proof — because the hidden band leaves the domain too, the y-axis top tick drops (600 → 340 when the largest of four series is muted); an axis that does not move after a toggle means the stack was repainted, not recomputed.
- Total-first tooltip — values are read off the hovered row and listed top-down to match the stack, then summed into a Total line, so the tooltip answers "what is this made of, and how much is it altogether" in one hover.
- All-hidden is a state — turning every series off lands on an explicit panel with a "Show all series" button instead of an empty axis frame, so the control can never leave the card in a dead end.
- Aligned-dates invariant — the schema refines that every series repeats the same date sequence, which is both what makes the pivot to row-per-date safe and what stops a misaligned feed from drawing a plausible but wrong stack.
- Monotone over natural — the curve type is chosen for correctness, not looks: a natural spline overshoots between points and on a stack that reads as bands crossing, while monotone keeps the bands nested.
- Image role scoped to the plot —
role="img"plus a generated summary sits on the chart container only, because it makes descendants presentational; the legend buttons stay outside it and the numbers are repeated in asr-onlytable.
Stat Card · Sparkline
A KPI card — headline figure, tone-aware delta badge and a minimal sparkline — with four data states from one contract.
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.