Chart Card
block / data / chart
Line, area, bar and donut charts in a card, drawn as inline SVG with honest axes, one hover readout and every state. No chart library.
- type
- block
- files
- 2
- registry deps
- button, card, skeleton, @automindz/empty-state
- npm deps
- lucide-react
installs
- lib/automindz/charts.ts
- components/automindz/chart-card.tsx
- Line, area, bar, stacked, grouped and donut — all inline SVG
- Nice-number axes; bars and areas never leave the zero baseline
- One hover readout for the whole chart, and the numbers in a hidden table
One data shape for every form: categories plus series[].values running parallel to it, where null is a gap and not a zero. Pick form: line, area, bar (with stacked or orientation="horizontal") or donut. A Date category formats itself for the span the axis shows. A single number is not a chart — pass value and delta with no data. data={undefined} renders the skeleton, an empty series the empty state, and error the failure with an awaited onRetry.
Variations
one state per section
- 01Trend over time, three accounts
- 02One series, volume is the point
- 03Categorical comparison, values on the cap
- 04Part-to-whole over time
- 05Two series side by side
- 06Share of one total
- 07More series than colours
- 08Compact, in a dashboard grid
- 09Loading
- 10Nothing to plot yet
- 11Failed to load
- 12A single point, and a gap
- 13Every value is zero
- 14Long category names
Trend over time, three accounts
Sweep across the plot with a pointer: the crosshair snaps to the nearest real month — never to the pointer's own position — and one readout lists **every** series at that month, so you never have to land on a line. Exactly one readout can exist, because "which category is open" is a single piece of state, and it is only mounted while something is active — nothing can be stranded by an exit animation that never ran. Twelve months across a year boundary, so the axis labels format as month-and-year rather than repeating the day.
One series, volume is the point
form="area" for a single series where the area under the line means something. The wash is the series hue at 10%, never a saturated block, and the axis includes zero — an area chart with a truncated baseline draws a shape whose size is a lie. Thirty daily buckets, so the axis prints day-and-month and thins the labels to the ones that fit; every date is still in the hidden table in full.
Categorical comparison, values on the cap
One bar per client, from a zero baseline — a bar encodes length, so a truncated axis misstates the ratio between two of them. Marks cap at 24px however wide the card gets, with the data end rounded and the baseline end square, and the value rides the cap because seven of them fit. Hover anywhere in a bar's slot: the whole band is the hit target, not just the painted pixels.
Part-to-whole over time
stacked turns the same bar into a part-to-whole. Segments are separated by a 2px gap in the card's own colour — never a border drawn around the mark — and the axis measures the column total rather than the tallest tier. The legend prints each tier's total for the whole range.
Two series side by side
Without stacked the series group inside each slot, 2px apart, and the slot still keeps about a third of its width as air. Seats in use against seats licensed is the honest form here: a stack would add them together, which would mean nothing.
Share of one total
A donut with the total in the middle. Six channels, five chart tokens: the tail folds into “Other”, which names what it swallowed in the readout and in the hidden table — a sixth colour is never invented. Arcs are dash lengths on one circle, each shortened by the 2px gap, so a hovered segment thickens without any arc trigonometry.
More series than colours
Seven accounts, five slots. The four largest keep their token and the rest become one “Other (3)” series, summed per month. Colour follows the entity, not its rank, so an account keeps its colour when the list is filtered — and the fifth slot is a real series, not a sixth invented hue.
Compact, in a dashboard grid
size="sm" drops the plot to 132px and thins the axis to the ticks that still fit, which is what makes three of these sit in a row without turning into decoration. The value and the delta carry the headline; the plot carries the shape.
Loading
data={undefined}. The skeleton is the plot's own height and keeps the axis gutter, so nothing moves when the numbers land — including the headline figure and the legend row.
Nothing to plot yet
Empty series, not a failed load and not a filtered-out result. Reuses @automindz/empty-state at the plot's height, so a card in a grid keeps its box instead of collapsing and shoving its neighbours around.
Failed to load
The failure replaces the plot, with role="alert", the reason spelled out and a retry that stays busy while it runs. Press it: this one succeeds. The header, the headline figure and the footer stay put — the card does not disappear because one query timed out.
A single point, and a gap
A brand-new workspace has one month of data. There is no path to draw, so the marker **is** the chart — without it a one-point series renders as an empty plot and reads as broken. The following month is null, which is a gap rather than a zero: the line stops instead of inventing a drop to the baseline.
Every value is zero
A real answer, not an empty state: there genuinely were no refunds. The axis still resolves to 0–1 instead of dividing by a zero range, the baseline is drawn, and the plot says so in words — six invisible bars would look like a bug.
Long category names
Five client names that would collide on a vertical axis, or truncate to three characters each. orientation="horizontal" is the answer the form gives: the names run down the side with the room they need, and the value axis takes the width. Vertical bars ellipsize instead and keep the full name in the readout and the table.
