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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-pareto.jsonPrompt
Build a React + TypeScript + Tailwind "ChartPareto" dashboard card on the shadcn
chart primitives (ChartContainer / ChartTooltip / ChartTooltipContent over
recharts ComposedChart) with zod.
Contract
- A zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
valueLabel: string; unit?: string;
items: { id, label, value >= 0 }[] }.
Ids are refined unique; "ready" requires at least one category.
- Feed order is explicitly NOT part of the contract: ranking is what makes this
chart a Pareto chart, so the component sorts. Document that in the schema.
- Component props = z.infer of the schema plus threshold? (cumulative % the
reference line sits on, clamped 1–100, default 80), maxCategories? (bars drawn
INCLUDING the merged tail, clamped >= 2, default 12), otherLabel? (default
"Other"), onRetry? and className. No hand-written parallel interface.
- Export the model builder (buildPareto) next to the component: the ranking, the
curve and the crossing point are the product, and they must be unit-testable
without a DOM.
Behavior
- ONE sort, read by everything. Sort descending by value with a stable tie-break
(label, then id) into a single array; the bars, the cumulative curve, the
"Other" bucket, the tooltip and the sr-only table all iterate that same array.
Two independent sorts is the classic Pareto bug: the curve then belongs to a
different ranking than the bars it sits on, and the chart is quietly wrong.
- Cumulative: accumulate the prefix sums left to right in the same order the
total was accumulated, so the final prefix is bit-for-bit equal to the total.
Round the CUMULATIVE to the display precision and take each bar's own share as
the difference between two rounded cumulatives — never round each share and add
them up, which is what makes three equal thirds print 33.3 + 33.3 + 33.3 and
stop the curve at 99.9%. Then clamp the last point to exactly 100: it is the
definition of the curve, not a measurement of it, and a long float
accumulation can land on 99.99999999999999, which prints 100.0 only by luck.
- Threshold crossing is the deliverable, not decoration. Because the curve is
monotone, the "vital few" are the leading run of bars; find the first bar whose
(rounded, i.e. displayed) cumulative reaches the threshold and state the answer
in words under the chart: "The 80% line crosses the cumulative curve at bar 5
of 12 — Wrong label, cumulative 84.0%." Repeat the count in the header stat and
in the chart's aria-label. Comparing against the rounded value keeps the
sentence consistent with the number the reader can see on the curve.
- "Other" bucket: when the ranked list is longer than maxCategories, keep the top
(maxCategories - 1) and merge the rest into one trailing bar whose id cannot
collide with a real one. It always occupies the last slot even when its
combined value outranks the bar before it — it is the tail by construction, and
re-sorting it inward would claim a rank the merged categories never had. Say
how many were merged in the footnote, the tooltip, the sr-only table and the
aria-label; a silently merged tail is indistinguishable from missing data.
- Guard the inputs where they arrive: NaN / Infinity / negative values cannot be
ranked, so they are dropped and the count is disclosed, not coerced to 0.
- The four states are first-class branches inside one bg-card panel:
- loading: descending skeleton bars plus a rising polyline silhouette,
aria-hidden.
- empty: outlined descending bars + "Nothing to rank yet" + one line.
- error: message + a "Try again" button rendered only when onRetry exists.
- ready: header stat + legend + chart + footnotes + sr-only data table.
A "ready" payload with nothing plottable (every value 0, or every row dropped)
renders the empty branch, never an axis with no domain.
Rendering & styling
- Dual axis, one baseline. Left axis = absolute values on a domain of [0, max]
where max is four equal steps of a round number (pick the step from a
1/1.2/1.5/2/2.5/3/4/5/6/8/10 ladder); right axis = [0, 100] with ticks
0/25/50/75/100. Both start at 0 and take four steps across the same plot box,
so the two zeros are the same pixel row and every gridline means something on
both scales. Letting either side auto-fit its domain is what makes a Pareto
curve lie.
- Colour is never the only channel: bars up to the crossing point are solid
var(--chart-1), the ones after it are filled with an SVG <pattern> hatch of the
same hue against var(--card), and the crossing bar carries a var(--foreground)
outline. The change of TEXTURE is the threshold. The cumulative line is
var(--chart-4) with dots, and the threshold line is a dashed
var(--foreground) rule — a line and a bar are already different shapes. Give
the pattern id a useId with the colons stripped: recharts writes it into
fill="url(#…)" and two cards on one page must not share it.
- Park the threshold's own label at the RIGHT end of its line: past the crossing
point the curve is above the threshold by definition, so that corner is always
empty, while the left end sits on top of the tallest bar. Draw it with a
var(--card) halo (paintOrder="stroke") and flip it below the line when the
threshold is too close to the plot top to fit above — at threshold 100 the text
is otherwise clipped by the svg edge.
- X ticks use interval="preserveStartEnd" + minTickGap so recharts measures the
rendered text and thins colliding labels on a narrow card instead of
overlapping them; every row stays readable in the sr-only table.
- Accessibility: ChartContainer is role="img" with a summarising aria-label
(rank count, total, where the threshold line crosses, how many were merged),
and the exact numbers live in a sr-only <table> beside it. Pass
accessibilityLayer={false} — recharts otherwise puts tabindex="0" on the <svg>,
which inside a children-presentational role="img" is a tab stop with no
accessible name. Put the sr-only class on a wrapping <div>, never on the
<table>: for a table box CSS width is only a minimum, so .sr-only cannot shrink
it and the full-width 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.
- Numbers use explicit "en-US" Intl.NumberFormat (compact on the value axis,
fixed 1 decimal on percentages); unit letters take a space ("120 min"), symbols
bind tight. cn() merges className; only semantic tokens, never a hex.
Customization levers
- Threshold: `threshold` is the whole conclusion — 80 is the convention, 70 and
90 are common in safety and reliability work. It drives the reference line, the
solid/hatched split, the header stat and the sentence together, so changing it
can never leave two of them disagreeing.
- Tail policy: `maxCategories` trades resolution for readability (8 for a compact
card, 20+ to disable merging entirely); `otherLabel` renames the bucket
("All other causes", "尾部合计"). Merge upstream instead if the tail needs its
own drill-down.
- Precision: PCT_DECIMALS is both the displayed decimals and the grid the
cumulative is rounded onto, so dropping to 0 re-derives whole-percent shares
that still end at exactly 100.
- Density: h-[280px] + px-6 suits a dashboard grid; h-[200px] and px-4 make a
compact card. Drop the left YAxis when the ranking matters more than the
magnitude, or drop the legend row and rely on the tooltip.
- Encoding: swap the hatch for a lower fillOpacity if your audience is colour-safe
by other means, or drop the crossing outline and mark the intersection with a
ReferenceDot on the curve instead. Keep at least one non-colour channel.
- Keyboard tooltip: to get recharts' arrow-key tooltip navigation back, turn
accessibilityLayer on and move the aria-label off the container — keep the
sr-only table either way.Concepts
- One sort, two marks — the bars and the cumulative curve are two views of the same ranked array, walked once. Ranking the bars in one place and accumulating the curve in another is the defining Pareto bug: both look plausible on their own, and together they describe two different datasets.
- Round the cumulative, difference the share — per-bar shares are derived by subtracting two rounded cumulatives instead of being rounded on their own, so the printed shares still add to exactly 100 and the curve cannot drift; the final point is then clamped to 100.0% because it is the definition of "all of it", not a measurement.
- Vital few / trivial many — the curve is monotone, so the bars that reach the threshold are always the leading run. They are drawn solid and the rest hatched, which puts the 80/20 split into texture rather than into a colour difference a reader may not be able to see.
- Crossing point as a sentence — the payload of a Pareto chart is "these first k of n causes explain 80% of it", so the card names k, names the bar, and repeats it in the header stat and the aria-label rather than leaving it to be eyeballed off a gridline.
- Other is the tail, not a rank — the merged bucket keeps the last slot even when its combined value outranks the bar before it, and every surface says how many categories went into it; a silently merged tail reads exactly like missing data.
- Two axes, one zero — the absolute axis is built as four equal steps of a round number so it lines up with 0/25/50/75/100 on the percentage axis. Two auto-fitted domains would put the bars and the curve on baselines that only look like one.
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.
Box Plot
A four-state box plot comparing distributions side by side — R-7 quartiles, Tukey 1.5×IQR whiskers that stop on real observations, de-overlapped outliers, and raw points for groups too small for quartiles.