Grouped Bar Chart
A four-state grouped bar chart that flips to horizontal rows when columns would go thinner than 10px, textures every series so colour is never the only cue, and keeps gaps distinct from zeros.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-bar-grouped.jsonPrompt
Build a React + TypeScript + Tailwind "ChartBarGrouped" 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;
subtitle?: string;
series: { key: string; label: string }[] // max 5
items: { id: string; label: string;
values: Record<string, number | null> }[] }
- `values` is keyed by series key. A number is a reading (negatives allowed);
`null` — or an absent key — means no reading was taken, which is NOT the same
as 0. Refinements: unique series keys, unique item ids, and "ready" requires
at least one series and one category.
- Props = z.infer of the schema plus orientation ("auto" | "columns" | "rows",
default "auto"), minBarWidth (default 10, clamped 4–40), patterned (default
true), formatValue?: (n: number) => string, onRetry?, className.
Behavior
- Grouped, not stacked: every series gets its own bar inside each category and
all bars share one baseline, because the reader's job is comparing series
against each other. Reach for a stacked chart only when the parts add up to
something meaningful and the total is the story.
- Orientation is a measurement, not a breakpoint. Observe the plot wrapper with
ResizeObserver and predict the column width recharts will produce:
((plotWidth - valueAxisWidth) / categories * (1 - 2*categoryGap)
- (series - 1) * barGap) / series.
Under minBarWidth, flip to horizontal rows, where each bar is a fixed 12px
thick and the chart's height grows with categories × series. Five series over
eight categories on a 375px card is 3px per column — the flip turns that into
40 bars of 12px that the page scrolls past. Note the factor of two on the
category gap: recharts drops it off *both* ends of a band, and forgetting that
inflates the prediction by ~20% right where the decision is made.
- Missing ≠ zero. A gap draws no bar at all; a measured zero draws a 2px stub
straddling the baseline (a custom Bar `shape`, since a zero-height rect is
invisible and would read as a gap). The tooltip prints "no reading", the data
table prints "no reading", and a line under the chart names the gaps, so the
distinction survives without hovering. NaN / Infinity from an unvalidated feed
are treated as gaps, never as 0.
- Negatives are first-class: the value domain is rounded out to whole ticks
around min and max, so zero is always a labelled tick when the data crosses
it, and a ReferenceLine draws the baseline (only when bars actually cross —
otherwise the axis already is the baseline). Bars run down/left from zero;
recharts signs the extent for those, so normalise before drawing.
- One tooltip per category, not per bar: read the row off payload[0].payload and
list every series ranked by value, gaps last, with a footer counting them.
Reading the row (not the hovered rectangle) is what lets a series with no
reading still get a line.
- Category ticks fit themselves: in columns, ticks stagger onto two rows and
ellipsize using the axis width and tick count recharts hands each tick; in
rows, one label per band in a gutter sized from the longest label.
- Four first-class states inside one bg-card panel: loading (grouped skeleton
with literal heights, aria-hidden), empty (outline bar groups + copy), error
(message + "Try again" only when onRetry exists), ready.
Rendering & styling
- Semantic tokens only. Series i is
`color-mix(in oklab, var(--chart-{i+1}) 62%, var(--foreground))` — the raw
ramp is single-hue and identical in light and dark, so its light end sits at
1.48:1 on a light card and its dark end at 1.31:1 on a dark one; mixing toward
--foreground pulls every step off whichever surface it lands on (measured
4.06:1 worst case in light, 3.94:1 in dark) and re-computes per theme.
- Colour is never the only channel: each series also gets a texture — solid,
45° hatch, -45° hatch, dots, vertical stripes — as an SVG <pattern> whose
stripes are painted in var(--card) so they read as cut-outs on any fill. The
legend and tooltip swatches use CSS twins of the same textures; mind that
SVG patternTransform rotate(45) and CSS 45deg mirror each other, so the two
diagonals need inverted angles or the legend points at the wrong series.
- Five series is the ceiling (five tokens × five textures). A longer feed draws
the first five and says "+N more series not drawn" in the legend rather than
silently reusing an identity.
- Accessibility: ChartContainer carries role="img" plus an aria-label
summarising size, orientation, extremes, sign and gap count; BarChart sets
accessibilityLayer={false} (it would otherwise put a nameless tab stop inside
a children-presentational subtree); the real data goes in a table wrapped in a
`div.sr-only` — never put sr-only on the <table> itself, since width is only a
lower bound for a table box and it will drag the page into horizontal scroll.
- Animation is JS-driven inside recharts, so `motion-reduce:` classes can't
reach it: read prefers-reduced-motion with useSyncExternalStore and pass
isAnimationActive={!reduced}.
Customization levers
- Orientation: force "rows" for long category names (they get a proper gutter
instead of staggered ticks) or "columns" when the card must keep a fixed
height; raise minBarWidth to flip earlier on dense feeds.
- Density: ROW_BAR (12px) and the 280px column height set the visual weight —
drop to 8px/220px for compact dashboards, but keep the height derived from
categories × series so nothing is ever clipped.
- Palette: swap the seriesFill formula for fixed per-key tokens (brand vs
competitor) when series have inherent identities; on a palette with real hue
separation set patterned={false} and let colour carry it.
- Value formatting: formatValue drives the tooltip, the axis ticks and the data
table at once, so currency or unit suffixes only need saying once; it falls
back to compact notation on the axis and full numbers elsewhere.
- Extras worth adding per project: a legend that toggles series visibility, or
LabelList value labels at the bar ends when a rows chart has few categories.Concepts
- Grouped, not stacked — bars sit side by side on a shared baseline so the reading task is "series A versus series B inside this category". A stack answers a different question (what the category is made of) and makes every segment above the first start from a moving base, which is why comparing them across categories is unreliable.
- Orientation as a measurement — the flip to horizontal rows is decided from the observed plot width and the predicted column width, not from a viewport breakpoint. A card in a narrow dashboard column gets the same treatment as a phone, and the failure mode it avoids (3px columns) never depends on guessing the container.
- Texture as the second channel — the default chart ramp is one hue with five lightness steps, so adjacent series land at 1.2:1 against each other. Identity therefore rides on a texture (solid / two diagonals / dots / stripes) that survives greyscale, colour-vision deficiency and a re-themed palette; colour only adds redundancy.
- Gap versus measured zero — a missing reading draws nothing and is named in the tooltip, the data table and a line under the chart; a real zero draws a 2px stub on the baseline. Back-filling gaps with 0 upstream is the common way a grouped chart starts lying, so the contract makes
nullthe only way to say "not measured". - Zero as a tick, not a coincidence — the domain is rounded out to whole tick steps, which guarantees a labelled 0 whenever the data crosses it and stops the axis from ending on a padded maximum that looks like a data point.
- Category-wide tooltip — one hover answers the whole group, ranked by value with gaps last, because the comparison the chart exists for is between series, not between a bar and its own neighbours in time.
Heatmap Matrix
A generic rows × columns value grid with sequential and diverging colour scales, a configurable domain, and missing values kept distinct from zero.
Pareto Chart
A four-state Pareto chart — bars ranked high to low with a cumulative-percentage curve on a second axis, a configurable 80% line, and the crossing bar named in words.