Charts

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.

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 type {
  ChartErrorBarsData,
  ChartErrorBarsItem,
  ChartErrorBarsMeasure,
} from "./chart-error-bars.contract"

export interface ChartErrorBarsProps extends ChartErrorBarsData {

Installation

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

Prompt

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.kind is 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 factork and level describe 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.

On This Page