Combo Chart (Dual Axis)
A four-state bar-plus-line combo chart whose two axes are built from one shared row structure, so a zero reading lands on the same gridline in both units and no gridline is meaningful on only one scale.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-combo-dual-axis.jsonPrompt
Build a React + TypeScript + Tailwind "ChartComboDualAxis" card on the shadcn
chart primitives (ChartContainer / ChartTooltip over a recharts ComposedChart)
with zod. It plots exactly two measures in two different units: one as columns
on the left axis, one as a line on the right.
Contract
- One zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
caption?: string;
bar: { key, label, unit?, points: { x: string; value: number | null }[] };
line: { key, label, unit?, points: { x: string; value: number | null }[] } }.
- Exactly one measure per axis. Two measures that share a unit belong on one
axis, not on two — say so in the schema comments, because "just add another
series to the right axis" is how a dual axis turns into a lie.
- Refine: ready needs at least one point; bar and line must share the same x
sequence point for point (a measure with nothing to report at some x carries
value: null there, never a short array); the two keys must differ. Every
refine guards its own dereferences — zod runs them all, so a later one still
executes after an earlier one failed and will throw on a ragged payload
instead of returning a validation error.
- Component props = z.infer of the schema plus gridlines? (target intervals,
clamped 2-8, default 4), lineBaseline? ("zero" | "fit", default "zero"),
onRetry? and className. Export the scale builder (buildDualAxisScale) next to
the component: the axis merge is the product, and it must be testable without
a DOM.
Behavior
- THE core: two independently fitted axes put their zeros on two different pixel
rows, so a column a third above zero can sit under a line point below zero and
half the gridlines mean nothing on one of the two scales. Build both axes from
ONE row structure instead:
1. For each measure take min/max with zero forced into the window (a column's
length is read from zero), pick a round step from a 1/1.2/1.5/2/2.5/3/4/5/
6/8/10 ladder at the right power of ten, and count how many steps it needs
BELOW zero and ABOVE it.
2. Merge: below = max(below), above = max(above). Grow the step and recount
while the merged grid would exceed 8 rows.
3. Re-derive each step as the tightest ladder value that still fits its own
data into the merged row counts. Skipping this costs real headroom —
measured: a merged 2-below/3-above grid keeps a 15000 step and gives the
columns 70% of the plot height, where re-deriving lands on 12000 and gives
them 88%.
Zero is then the same tick INDEX on both axes, and equal tick indexes are
equal pixel rows inside one plot box, so the two zeros are one line and every
gridline carries a value in both units.
- lineBaseline="fit" trades that away deliberately: the right axis hugs the
line's own range (floor the start onto a multiple of the step) so a rate that
lives between 2% and 4% uses the whole plot height instead of the top fifth.
Grant it ONLY when the columns stay on one side of zero, and refuse it when
the fitted window would still contain zero — a grid carrying two zero rows at
two heights is worse than a flat line. Whatever it lands on, say it in the
footnote and in the aria-label.
- Honesty about what the picture does not prove: where the line crosses the
columns is an artefact of two scales, not a fact about the data. The card
states that under the chart. The zero row is the one shared fact.
- Values are matched by index AND by x. NaN, Infinity and a reading whose x does
not line up all become gaps: no column is drawn and the line breaks
(connectNulls={false}) instead of running straight through a reading nobody
took. Count the gaps per measure and disclose them in a footnote.
- Numbers print at the precision of their own step: compact past five digits,
scientific below a thousandth (a fixed-decimal formatter rounds a 0.000025
tick to "0", which is a different number and, on a zero-aligned grid, a wrong
claim). Explicit "en-US" locale, never Intl(undefined). The tooltip and the
data table go one digit finer than the axis and are never compacted.
- Four first-class branches in one card: loading (skeleton columns plus a curve
silhouette in the same box, so nothing shifts when data lands), empty, error
(message plus a "Try again" button only when onRetry exists), ready. A "ready"
payload with no x positions renders the empty branch, never two axes over
nothing.
Rendering & styling
- ComposedChart with CartesianGrid vertical={false}, one XAxis, and two YAxis
with explicit yAxisId ("bar" left, "line" right). Both get explicit ticks and
interval={0} — letting either axis thin its own ticks breaks the
index-for-index pairing that puts the two zeros on one row. Pin the grid to
one yAxisId so the horizontal lines have a defined owner.
- When the merged grid crosses zero, draw a var(--foreground) ReferenceLine at
y=0: it belongs to both axes at the same row, and drawing it is what makes the
claim checkable by eye.
- X ticks use interval="preserveStartEnd" + minTickGap so recharts measures the
rendered text and thins colliding labels on a narrow card rather than
overlapping them; every row stays readable in the sr-only table.
- Colour is never the only channel: the columns are var(--chart-1) and the line
var(--chart-4), but a bar and a stroke are already different shapes, and the
legend names each measure's axis in words ("Orders · left axis"). Two chart
tokens can be only ~1.3:1 apart, so "the blue one" is not a way to tell a
reader which axis to read. Chart tokens are fills and strokes only — never
text colour; labels stay on foreground / muted-foreground.
- Accessibility: ChartContainer is role="img" with a summarising aria-label
(both ranges, the shared gridline count, what the right axis is anchored to),
and the exact numbers live in a sr-only <table> beside it. Pass
accessibilityLayer={false} — recharts 3 otherwise puts tabindex="0" on the
<svg>, which inside a children-presentational role="img" is a tab stop with no
accessible name. Put .sr-only on a wrapping <div>, never on the <table>: for a
table box CSS width is only a minimum, so .sr-only cannot shrink it (measured
2.3-2.7k px) and the table pushes the page into horizontal scroll.
- recharts animates in JS, so motion-reduce: classes cannot reach it: read
prefers-reduced-motion with useSyncExternalStore (server fallback false) and
pass isAnimationActive={!reduced} to both the Bar and the Line.
- cn() merges className; semantic tokens only, never a hex.
Customization levers
- Baseline: lineBaseline is the whole editorial choice. "zero" is the honest
default and the only one that survives negative columns; "fit" is for a rate
that never approaches zero and whose variation is the story. Both are
disclosed in the footnote, so neither can quietly mislead.
- Grid density: gridlines (2-8) trades resolution for calm. Low values give
round, sparse rows; high values give a fine grid and, on signed data, a taller
merged structure. The 8-row ceiling is where the step starts growing instead.
- Step ladder: STEP_LADDER decides what counts as a round number. Drop 1.2 and
3 for a strict 1/2/5 axis, or add 7.5 for domains that are naturally eighths.
- Marks: swap Bar for a second Line (both axes then read as trends), give the
Bar a stackId if the left measure arrives pre-split, or set maxBarSize higher
for a card with few categories. Add a Brush under the XAxis for long series.
- Palette: BAR_COLOR / LINE_COLOR are single constants — point them at any
var(--chart-N) pair. Keep them far apart in the ramp, and keep the "· left
axis" / "· right axis" wording, which is the channel that survives a reader
who cannot separate the two hues.
- Density: h-[280px] plus px-6 suits a dashboard row; h-[200px] and px-4 make a
compact card. The footnote paragraph is the first thing to drop when the
audience already knows how to read a dual axis.Concepts
- Shared row structure — the two axes are not fitted separately and then hoped to agree. One merged count of rows below zero and rows above it is computed once, and each axis renders that same structure with its own round step. Equal tick indexes are equal pixel rows inside one plot box, which is why the alignment is structural rather than a coincidence of the data.
- Zero-line coordination — with two independently fitted axes, zero lands at a different fraction of each domain: on this component's own signed demo data that is 40% versus 25% of the plot height, i.e. two zero rows 35.7px apart at a 280px plot. Merged, the measured gap is 0px and one
ReferenceLinecan honestly be drawn for both. - Tighten after merging — merging usually asks one side for rows it did not need, which shows up as dead headroom. Re-deriving each step as the smallest ladder value that still fits its data into the agreed rows buys it back: measured 70% → 88% of the plot height used by the columns on the signed dataset.
- Fitted baseline is a trade, not a mode —
lineBaseline="fit"gives the right axis its own window so a 2–4% rate stops being a flat line near the floor. It costs the shared zero, so it is granted only when the columns never go negative, refused when the fitted window would contain zero anyway, and always stated in the footnote. - Crossing is an artefact — a dual axis can make any two series appear to cross wherever you like, because both scales are choices. The card says so under the chart. The shared zero row is the one fact the two scales genuinely agree on.
- Gaps stay gaps — a null, a NaN, or a reading whose x does not line up draws no column and breaks the line rather than being interpolated or coerced to zero, and the footnote counts how many readings each measure is missing. A silently bridged outage is indistinguishable from a healthy week.
Arc Diagram
A four-state arc diagram in plain SVG — nodes on one sequence-preserving baseline, half-ellipse arcs sized by weight, back-edges below the axis, hover to raise a node's arcs, and a keyboard walk over an sr-only node and arc table.
Legend Toggle
A standalone, controlled chart legend — aria-pressed chips in a roving-tabindex toolbar, double-click or Enter to isolate one series, show-all / hide-all, and a guard that keeps the last series from blanking the chart.