Error Bars
A four-state categorical error-bar chart: one point estimate per category with an interval whose meaning — SD, SE, CI or custom bounds — is declared in the contract and printed in the legend, tooltip and data table.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-error-bars.jsonPrompt
Build a React + TypeScript + Tailwind "ChartErrorBars" card on the shadcn chart
primitives (ChartContainer/ChartTooltip over recharts) with zod. One point
estimate per category, each wrapped in an interval whose *meaning* is part of
the contract.
Contract
- A zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
valueLabel: string; unit?: string;
measure: { kind: "sd" | "se" | "ci" | "custom"; k?: number;
level?: number; label?: string; note?: string };
reference?: { value: number; label?: string };
items: { id: string; label: string; value: number;
error?: number; lower?: number; upper?: number;
n?: number }[] }
refined so status "ready" requires at least one item, and so kind "custom"
must carry a label.
- `measure` is the reason this component exists. Over one sample a ±1 SD bar
and a ±1 SE bar differ by a factor of √n — for n = 120 that is 11× — and a
95% CI is different again. So the kind is required, not optional, and it is
rendered in four places: under the title as a sentence, in the legend, as
the tooltip row name, and as the data-table column header. Built-in labels:
"±k SD", "±k SE", "<level>% CI", or the caller's own for custom.
- `k` and `level` are NAMING fields, not scale factors. The component never
multiplies anything: only the caller knows n and the distribution, so the
interval arrives already computed. For k = 2 send error = 2 * se. Say this in
the field's JSDoc — a component that quietly rescaled the input would be the
same lie the whole design is against.
- An interval arrives either as `error` (symmetric half-width, an OFFSET from
value) or as `lower`/`upper` (ABSOLUTE positions on the value axis). Absolute
bounds win where both are given, a lone edge is a one-sided interval with the
missing edge on the estimate, and an item with neither is a bare estimate —
a hollow dot plus a "no interval" legend entry, never a zero-width bar that
would read as a certainty it never claimed.
- Deliberately do NOT refine lower <= upper, or value inside its interval. A
real feed sends swapped columns, negative half-widths and estimates outside
their own bars; the component repairs what has one correct reading and
discloses the rest (below).
Behavior
- A pure buildErrorBarsRows(items, reference) maps the feed to plot rows and
counts four classes:
1. non-finite value -> row dropped. The category keeps its axis slot and its
"no data" line in the table, but nothing is drawn: an interval with no
estimate inside it is not an error bar.
2. lower > upper, or a negative half-width -> repaired (swap / abs), counted.
"The interval from a to b" is the same set as "from b to a" and a
half-width is a distance, so both have exactly one correct reading.
Skipping this repair does not merely look wrong: the row's drawn range
becomes [min(lower,value), max(upper,value)], which for swapped bounds
collapses to a single value — measured, the bar went from 45px to 0px and
vanished from the plot while the table still printed "149.6 - 126.8".
3. estimate outside its own interval -> NOT repaired. Clamping the dot would
misreport the estimate and stretching the bar would misreport the
uncertainty. The truth is drawn and the dot is painted in
var(--destructive), with a tooltip line saying it was drawn as sent.
4. no interval declared -> hollow dot, counted as "no interval".
- Non-zero counts collapse into one line under the chart, e.g. "Feed repaired:
2 intervals re-ordered · 1 estimate outside its interval (marked) · 1
category dropped (non-finite)." Never repair silently.
- Optional reference line (a control, an SLA, zero). It joins the axis domain
so it can never sit off-screen, and any interval that covers it is flagged in
the tooltip, the data table and the aria summary: "interval covers Control"
is the single most useful reading of an A/B error-bar chart, and it is the
one the dots alone hide.
- Four first-class branches of one bg-card panel: loading (a pulsing dot-and-
whisker silhouette at literal percentages, aria-hidden, so the card keeps its
height), empty (a dashed whisker outline + "No estimates yet"), error
(message + a "Try again" button only when onRetry exists), ready. A "ready"
payload with nothing placeable renders the empty branch rather than an axis
with no domain.
- No animation: isAnimationActive={false}. A custom shape animated by y/height
would slide every dot off its value mid-flight, and switching it off makes
prefers-reduced-motion a non-issue rather than a special case.
Rendering & styling
- One recharts BarChart. Each row hands the Bar a floating range
[min(lower,value), max(upper,value)] and a custom shape draws inside the
pixel box recharts returns: whisker stem, two caps, then the dot. Everything
is interpolated from that box, so the marks stay on the axis scale at any
width (verified against recharts' own y ticks: max 0.24px disagreement across
six data sets and two viewport widths).
- Value axis: data plus a 6% margin, never snapped out to zero — anchoring
[96, 176] at 0 spends 55% of the plot on empty space and flattens every bar
into the same stub. Round numbers come back through an explicit tick list
(1 / 2 / 2.5 / 5 × 10^n steps, nearest rung), and CartesianGrid syncWithTicks
so no unlabelled rule appears at the domain edge.
- Category axis: each tick is a foreignObject exactly one band wide holding a
line-clamp-2 div with overflow-wrap:anywhere. Let the browser lay the text
out — measuring text in JS is wrong (measured elsewhere in this repo: 29%
under for all-caps, 30% over for digits) and recharts' fallback is to drop
whole labels, which here would leave a dot with no identity. At 375px nine
categories give 27px per band and "us-east-1" survives as "us-/ea…" while
"us-west-2" reads "us-/we…" — still distinguishable. Without overflow-wrap
the same box cuts a 47px word mid-glyph with no ellipsis.
- Colour carries no data: categories are labelled on the axis, so one token
(var(--chart-1)) paints every stem, cap and dot. The three marks that differ
are separated by shape and fill — filled dot (has an interval), hollow dot
(none declared), var(--destructive) dot (outside its interval) — so the chart
survives a monochrome palette and greyscale printing. Chart tokens are used
as fill/stroke only, never as text colour.
- Tooltip: category, n, the estimate, the interval under its real name, and the
half-width — printed as "± x" only when the two sides agree, otherwise as
"−a / +b", because "±" on a bootstrap interval is a claim the data never
made.
- Accessibility: accessibilityLayer={false} (recharts otherwise puts a
focusable, unnamed svg inside the role="img" wrapper, which is a dead tab
stop). The container is role="img" with an aria-label naming the measure, its
meaning, the extremes and the reference crossings; the exact numbers live in
a visually hidden table with one row per category and a Note column. Put
sr-only on a wrapping <div>, not on the <table>: CSS width:1px is only a
*minimum* for a table box, so a bare sr-only table stays content-wide and
pushes real horizontal page overflow (measured here: 1194px of it).
Customization levers
- Measure semantics: kind + k/level/label/note is the whole knob. Swap "se" for
"ci" and the legend, tooltip header, table header and the sentence under the
title all change together; add `note` to overwrite the explanation with house
wording ("bootstrap, 500 resamples").
- Symmetric or asymmetric: send `error` for the ± case, `lower`/`upper` for
ratios, log-scale intervals and percentile bounds. Both may appear in one
feed; the tooltip switches its own wording.
- Reference line: drop `reference` and the line, its legend entry and every
"covers the reference" note disappear. Point it at 0 for lift, at an SLA for
latency, at a control mean for an experiment.
- Density: h-[260px] and px-6 suit a dashboard grid; h-[180px] with the legend
dropped gives a compact card. Widen YAxis past 48px when values need more
than four digits, and raise the tick line count from 3 to 4 for taller cards.
- Palette: swap var(--chart-1) for any --chart-N. Keep the three dot styles
distinct by fill, not by hue, or the chart stops working in greyscale.
- Repair policy: the four classes are independent. Turn "outside" into a hard
error branch if a contradictory row should block the whole card, or drop the
one-sided fallback if your feed guarantees both edges — but keep the
disclosure line either way.Concepts
- The bar has to name itself — an SD bar, an SE bar and a 95% CI drawn from one sample differ by factors of √n and of the critical value, so an unlabelled error bar supports three incompatible readings of the same picture.
measure.kindis required, and it is rendered in four places (title sentence, legend, tooltip row, table header) so the reading cannot drift away from the drawing. - Naming field, not scale factor —
kandleveldescribe an interval that arrived already computed, because only the caller knows n and the distribution. A component that silently multiplied its input would be committing the exact error it exists to prevent, one layer down. - Discrete intervals, not a band — each category owns an independent interval; nothing is interpolated between neighbours, so no envelope is drawn and the categories can be reordered freely. That is what separates this from a continuous uncertainty band over time, where the shape between two points is itself a claim.
- Repairable vs. contradictory — swapped bounds and a negative half-width are notation errors with exactly one correct reading, so they are fixed and counted. An estimate outside its own interval has no correct reading, so it is drawn as sent and marked: clamping the dot would misreport the estimate, widening the bar would misreport the uncertainty, and both would turn a data bug into a plausible-looking chart.
- Bare estimate vs. zero-width bar — "we never computed an interval" and "the interval is zero wide" are different statements. The first gets a hollow dot and a legend entry; giving it a zero-width bar would silently promote missing information into a claim of perfect precision.
- Overlap with the reference is the finding — when a baseline is supplied, the intervals that cover it are exactly the categories the data cannot separate from it. Reading that off the dots alone is what produces confident rankings of noise, so it is stated in words in the tooltip, the table and the screen-reader summary.
- Shape carries the encoding, not hue — one chart token paints every mark and the three dot states differ by fill and outline, so the chart still reads on a monochrome palette, in greyscale print, and for a reader who cannot separate adjacent series colours.
Alluvial Diagram
A four-state alluvial that re-draws the same categories at every reading — band width is how many members moved, one global category order chosen by measured crossing cost, and a retained / arrived / left readout for whatever you point at.
Regression Scatter
A four-state scatter that fits its own least-squares trend — linear, logarithmic or exponential — draws the confidence or prediction band beside R², slope and sample size, flags outliers by studentised residual, and refuses to invent a fit it cannot compute.