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…

"use client"

import * as React from "react"
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"

import { type ChartConfig, ChartContainer, ChartTooltip } from "@/components/ui/chart"
import { cn } from "@/lib/utils"
import type { ChartAreaStackedData } from "./chart-area-stacked.contract"

export interface ChartAreaStackedProps extends ChartAreaStackedData {
  /** retry affordance for the error state; the button is omitted when absent */
  onRetry?: () => void
  className?: string
}

Installation

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

Prompt

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 hide keeps 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), the hide variant 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 plotrole="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 a sr-only table.

On This Page