Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Bar, BarChart, CartesianGrid, ReferenceLine, XAxis, YAxis } from "recharts"

import { type ChartConfig, ChartContainer, ChartTooltip } from "@/components/ui/chart"
import { cn } from "@/lib/utils"
import { useResizeObserver } from "@/registry/hooks/use-resize-observer"
import type { ChartBarGroupedData, ChartBarGroupedSeries } from "./chart-bar-grouped.contract"

export interface ChartBarGroupedProps extends ChartBarGroupedData {
  /**
   * `"columns"` draws vertical bars, `"rows"` draws horizontal ones.
   * `"auto"` (default) measures the card and flips to rows as soon as a column

Installation

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

Prompt

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 null the 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.

On This Page