Charts

Small Multiples

A four-state trellis grid — one repeated mark per category, every panel on one shared y scale with a free-scale toggle that states its cost, axis labels only on the edge panels, and a measured column count that wraps rather than shrinking cells past legibility.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Area, Bar, CartesianGrid, ComposedChart, Line, XAxis, YAxis } from "recharts"

import {
  type ChartConfig,
  ChartContainer,
  ChartTooltip,
  ChartTooltipContent,
} from "@/components/ui/chart"
import { cn } from "@/lib/utils"
import type {
  ChartSmallMultiplesData,

Installation

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

Prompt

Build a React + TypeScript + Tailwind "ChartSmallMultiples" component — a small
multiples (trellis) grid — on the shadcn chart primitives (ChartContainer /
ChartTooltip over recharts) with zod.

Contract
- One zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready"; title: string;
    caption?: string; facetBy: string; measure: string; unit?: string;
    facets: { id: string; label: string;
              points: { x: string; y: number | null }[] }[] }.
  facetBy names the dimension the grid is split by ("region"), measure names
  what every panel plots; both feed the summary sentence and the data table.
- y is nullable because a gap is not a zero: the mark breaks at a missing
  reading instead of drawing a straight run through it.
- Refine that ready carries at least one facet and that facet ids are unique
  (duplicate ids collide as React keys). Each refinement guards its own inputs —
  zod runs them all, so the second cannot assume the first saw a well-formed
  payload.
- Props = z.infer of the schema plus mark?: "line" | "area" | "bar" (default
  "line"), minFacetWidth?: number (default 156, clamped to 96–480, non-finite
  falls back to the default), defaultScale?: "shared" | "free", onRetry?:
  () => void and className. No parallel hand-written interface.

Behavior
- One shared x sequence: take the union of every facet's x values in order of
  first appearance and align every panel onto it, filling what a panel does not
  report with null. A panel with six of twelve weeks then occupies the right six
  positions instead of stretching across the whole axis. Duplicate x inside one
  facet: first wins, deterministically.
- Shared scale is the default and the entire point of the form: derive one y
  domain from every value in every panel, rounded out to a 1/2/5 x 10^k step so
  the ticks are readable numbers. Panels are then comparable by position alone.
- Offer "free scale" as an explicit toggle (a button with aria-pressed), never
  as the default, and state the cost in the card header: under a free scale each
  panel is scaled to its own data, so panel heights stop meaning anything and
  the only honest reading left is shape. The toggle must also change what the
  axes do — see the labelling rule below.
- Guard the degenerate domain: no values, one value, or every value identical
  all produce a zero span, which divides by zero. Expand it to [0, 2v] (or
  v +/- pad off zero). Without this a flat panel renders one lone tick that
  states no range at all.
- Anchor bar and area at zero (their filled length encodes magnitude, so a
  floating baseline lies); leave line domains free of zero so small movements
  stay visible.
- Panel count is not capped and panels are not shrunk: measure the grid with a
  ResizeObserver, take columns = floor((width + gap) / (minFacetWidth + gap)),
  clamp to [1, facet count], and set grid-template-columns explicitly. Thirty
  panels wrap into more rows; they never turn into slivers. Do the first
  measurement inside the observer callback (observe() delivers one), not in the
  effect body.
- Below a 140px panel the header cannot hold a label and a figure at once —
  measured, the label was squeezed from 84px to 42px — so under that width the
  peak figure is dropped and the label takes the whole header. The peak stays in
  the panel's text alternative and in the data table.
- Marks carry dots (r 1.6): a reading with no neighbour draws a zero-length
  segment, which paints nothing, so dot={false} would make an isolated reading
  invisible. Under the bar mark a zero and a gap look the same — say so, and
  reach for line or area when the difference matters.
- Four first-class branches in one card: loading (placeholder panels on the same
  wrapping rule so nothing jumps), empty (no values for the dimension yet),
  error (message plus a "Try again" button only when onRetry exists), ready.
- No animation anywhere: recharts isAnimationActive={false}. Two dozen panels
  growing on mount is noise, and it makes prefers-reduced-motion a non-issue.

Rendering & styling
- Every panel is one small recharts ComposedChart carrying exactly one mark —
  small multiples is one shape repeated, not a dashboard of different charts.
- Equal geometry is the premise, so the axis gutters (about 40px on y, 18px on
  x) are reserved in *every* panel whether or not that panel prints ticks.
  Widening the plot only in the labelled column leaves panels whose identical
  series draw at different widths.
- Axis labels only on the edges: y ticks on the first column, x ticks on the
  last panel of each column (index + columns >= count, so a short final row does
  not orphan the column above it). Repeating twelve identical tick sets is
  noise. Edge-only labelling is a privilege of the shared scale — once free
  scale is on, every panel needs its own y ticks.
- Pass the three y ticks explicitly ([low, middle, high]) rather than letting
  recharts round them, and pick the tick number format from a chain — compact
  1dp, compact 2dp, plain, plain 2dp — stopping at the first that prints the
  three as *distinct* strings. A panel spanning 1,150-1,300 otherwise reads
  "1.2K, 1.2K, 1.3K": two gridlines claiming the same value.
- Printed figures (panel peak, tooltip, sentences) use one notation chosen once
  from the shared domain, so "8,200" in one header never sits beside "12K" in
  the next. Explicit "en-US" locale, never Intl(undefined).
- Colour: one token, var(--chart-1), for every panel. The panel label is the
  identifier; a different hue per panel would encode nothing and break the
  "same mark everywhere" reading. Everything else is semantic tokens — bg-card,
  border, text-muted-foreground — so both themes come free and no chart token
  is ever used as text colour.
- Inset the plot by a few pixels: recharts pins the last x tick's label to the
  svg edge to avoid clipping it, and without the inset that edge is the panel
  border (measured 0.75px of slack).
- Accessibility: accessibilityLayer={false}, the card is role="group" with a
  one-sentence summary (panel count, scale mode and its consequence, the highest
  peak), each panel is role="img" with its own sentence (readings, span, peak,
  latest, its scale), and one sr-only data table carries every number. Wrap that
  table in its own div: width:1px is only a lower bound on a table box, so a
  bare sr-only table still takes layout space.

Customization levers
- Mark: "line" for trends, "area" when the filled magnitude matters, "bar" for
  discrete categories per panel. Whatever you pick, keep it the same in every
  panel — that is what makes the grid readable at a glance.
- Density: minFacetWidth is the readability floor and therefore the column
  count; raise it to 220 for roomier panels and fewer columns, lower it toward
  the 96px clamp for a dense wall. The 96px floor and the 140px compact-header
  threshold are the two numbers to retune for a different type scale.
- Panel height (CHART_H) and the axis gutters (AXIS_W / AXIS_H) are single
  constants; change them together and every panel stays identical.
- Scale: ship defaultScale="shared"; lift the toggle to a controlled prop if a
  dashboard has to keep several grids in step, and drop it entirely for
  audiences who should never see incomparable panels.
- Header: the peak is one derived figure — swap it for the latest reading or a
  period delta by changing the one place it is computed, and it flows to the
  header, the sentence and the table together.
- Add-ons worth their weight: a muted "all panels combined" reference series
  behind each panel for context, or sorting facets by peak so the grid reads
  top-left to bottom-right.

Concepts

  • Trellis, not dashboard — every panel repeats the same mark on the same axes; only the slice of data changes. That is what lets the eye compare by position instead of decoding a legend, and it is why all panels share one colour: the label identifies the panel, the hue carries nothing.
  • Shared scale, and the price of leaving it — one domain across all panels is what makes heights comparable, and it is also what flattens the small panels when one is 200 times the rest. Free scale is offered as an explicit toggle that says what it costs, not as a silent default: with it on, shapes get louder and heights stop meaning anything.
  • Edge-only axis labels — twelve panels printing the same twelve tick sets is repetition, not information, so ticks are drawn on the first column and the last panel of each column. It is a privilege of the shared scale: the moment each panel has its own domain, a panel without ticks is unreadable, so free scale re-labels every panel.
  • Equal geometry — the axis gutters are reserved in every panel, labelled or not. Reclaiming that space only where ticks are drawn would give the labelled column a wider plot, and the same series would draw a different shape there.
  • Measured columns, not breakpoints — a ResizeObserver reports the real width and the column count is derived from the per-panel floor, so the grid wraps into more rows instead of squeezing cells. No panel is dropped and no list is silently capped; twenty-four facets render twenty-four panels.
  • Distinct ticks — the three y ticks are passed explicitly and the number format escalates until the three print as different strings. Two gridlines that both read "1.2K" are a scale that lies, and the narrow domains created by free scale produce exactly that.

On This Page