Charts
BarChart
Discrete categories compared by length.
Usage
When to reach for it
Ships from@misoto22/design/charts
import { BarChart } from '@misoto22/design/charts'Notes
Discrete categories compared by length — the shape for "how much, per thing".
Reach for <AreaChart> or <LineChart> when the axis is continuous and the reader is following a trend rather than comparing buckets.
Anatomy
| Element | Description |
|---|---|
| Figure framerequired | ChartFigure’s <figure>, named by title whether or not the page prints a heading above it. |
| Barsrequired | <BarChart.Bar>, drawn through a custom shape: a transparent rectangle for the hit area, then the painted bar three pixels shorter than its slot so a stacked segment keeps a hairline of page between it and the one above. A bar shorter than that trim is floored at one pixel rather than taken to nothing, so a small count is never pixel-identical to an absent one. |
| Axes | <BarChart.XAxis> and <BarChart.YAxis>, both flat by default — no tick line, no axis line. Every Recharts prop passes straight through, domain included, which is the door a truncated baseline comes in through. |
| Legend | <BarChart.Legend>. With isClickable each entry is a real <button> carrying aria-pressed rather than a div with a handler, which is the difference between a filter a keyboard can reach and one it cannot. |
| Value labels | <BarChart.Values>, a slot composed inside a bar. show defaults to last; all is for five or six bars where the exact figures are the point, and past that it is a table wearing a chart. |
| Hidden data table | The sr-only table of the FULL data rather than of the brushed window, so a reader on the table is never shown less than the CSV export holds. hideDataTable removes it, and zero rows render nothing. |
| Empty state | ChartEmpty, rendered in place of the plot when data is empty — a title, a reason and an optional action, so a filter that matched nothing is told apart from a load that failed. empty={false} keeps the bare axes instead. |
Best practices
Do
- Reach for buffer on a period still open. It hatches the last ROW rather than the last bar on screen, so brushing back into the middle of the range hatches nothing — a month that closed in March is never drawn as still being counted.
- Leave the value axis anchored at zero. A bar encodes by LENGTH from the baseline, so a domain of ["dataMin", "dataMax"] passed through <BarChart.YAxis> turns a two percent gap into a doubled bar. This is the distortion a bar chart cannot survive and a LineChart can: a line encodes by slope, so clipping its domain rescales the reading rather than inventing one.
- Reach for orientation="horizontal" when the category names are long. The alternative is a tick label rotated under every column, and a rotated label is slower to read than the bar it names.
- Pass a tickFormatter to <BarChart.YAxis> under stackType="percent". Unlike AreaChart’s expanded stack, which swaps in percentTick itself, the bar chart’s axis keeps defaultTick — so a normalised chart reads 0 to 1 instead of 0% to 100%.
- Put the series that has to be compared across categories at the BASELINE of a stack. Only the bottom segment starts at zero; every band above it floats on the ones below, and reading a third band across twelve months is a comparison the eye cannot make. When that comparison is the point, group the bars instead.
Don’t
- hideDataTable leaves no exact figure anywhere. defaultTick compacts at ten thousand and above, so the axis says 1.2M and so do the <BarChart.Values> labels; the sr-only table, where every cell is a full toLocaleString, was the only place the real number was written.
- Twenty bars at variant="default" is a wall rather than twenty values. stripped draws a 2px cap over a wash and stays countable at that density, which is the density it exists for.
- Do not close barCategoryGap up. The space between groups is the only thing telling a reader that two adjacent bars are two series rather than two categories, so a grouped chart with no category gap reads as a stacked one.
Examples
default
Two bar series over one category axis, which is the shape to reach for when the categories are discrete rather than a continuum. Each series takes the next slot on the grey ramp in declaration order, so hiding one never repaints the survivors; the second carries a hatch, because a step of grey alone is a thin thing to ask a reader to hold in their head. That is also why the legend is not optional above one series.
| month | Desktop | Mobile |
|---|---|---|
| Jan | 186 | 80 |
| Feb | 305 | 200 |
| Mar | 237 | 120 |
| Apr | 273 | 190 |
| May | 209 | 130 |
| Jun | 314 | 240 |
fill variants
Six ways to fill a bar, cycled on one series so they are comparable. stripped is the one that survives density best: it rounds only the top corners and prints a 2px cap of the series colour above a wash, so at twenty bars it still reads as twenty distinct values where a solid block has become a wall. The rest trade weight for texture, and with no hue to spend this is what separates two series before the ramp does.
| Desktop |
|---|
| 186 |
| 305 |
| 237 |
| 273 |
| 209 |
| 314 |
stacking and orientation
Two choices that interact. Stacking asks a different question at each setting — default compares, stacked totals, percent shares — while orientation is about the category names: "Organic search" reads straight beside a row and has to be rotated or truncated under a column. The axes swap with it, which is the one part the call site does by hand: the category dataKey moves from the X axis to the Y, and that axis needs a width wide enough for the longest label.
| Desktop | Mobile |
|---|---|
| 186 | 80 |
| 305 | 200 |
| 237 | 120 |
| 173 | 190 |
emphasis
Three ways to say "this one", side by side. A monochrome chart has no brighter colour to reach for, so glowing puts a halo behind the series that is the point of the figure; buffer draws the last category as an open hatch, the idiom for a period still in progress, the same height as any other bar but visibly not the same kind of fact; and hover highlight drops every bar the pointer is not on to 30%. That last one is driven by the pointer, so it can never be the only thing carrying a reading.
| Desktop |
|---|
| 186 |
| 305 |
| 237 |
| 273 |
| 209 |
| 314 |
| Desktop |
|---|
| 186 |
| 305 |
| 237 |
| 273 |
| 209 |
| 314 |
| Desktop |
|---|
| 186 |
| 305 |
| 237 |
| 273 |
| 209 |
| 314 |
interaction
What a reader can do with a chart rather than what it looks like. The legend entries are real buttons with aria-pressed, so Tab reaches them and Enter toggles a series — a styled div with a click handler could do neither — and the bars take a click to select for the same reason. The tooltip's ground and its corner are the two knobs worth exposing, and defaultIndex opens it on a row before anything is hovered, so the panel is legible in a screenshot and to a reader who never moves a pointer. Both knobs move the floating panel and nothing else on the card — and frosted is the plot showing THROUGH that panel, so it reads where the panel crosses a bar and nowhere else: over the white ground it is white at 75% over white, which is white.
| Desktop | Mobile |
|---|---|
| 186 | 80 |
| 305 | 200 |
| 237 | 120 |
| 273 | 190 |
brush
Twenty-eight bars is where a category axis stops being readable, and the brush keeps the whole series in view while the reader chooses a slice of it. The strip below draws the same rows with the bar mark, so what is being chosen from looks like what is being read — a control that shows only the zoomed result cannot do that. The chart above then renders just the selected window, so everything reading the visible rows follows the selection with it.
| day | Desktop |
|---|---|
| D1 | 90 |
| D2 | 176 |
| D3 | 241 |
| D4 | 270 |
| D5 | 254 |
| D6 | 198 |
| D7 | 115 |
| D8 | 153 |
| D9 | 226 |
| D10 | 266 |
| D11 | 263 |
| D12 | 217 |
| D13 | 140 |
| D14 | 129 |
| D15 | 208 |
| D16 | 259 |
| D17 | 268 |
| D18 | 234 |
| D19 | 164 |
| D20 | 104 |
| D21 | 188 |
| D22 | 248 |
| D23 | 270 |
| D24 | 248 |
| D25 | 187 |
| D26 | 102 |
| D27 | 166 |
| D28 | 235 |
loading
The skeleton, with its bar count set by hand. loadingBars decides how many bars are drawn and otherwise defaults to twelve, which matters more on a bar chart than on a line: a skeleton of twelve followed by six real bars reads as the chart having lost half its data. The marks are swapped but the measured height is kept, and a brush or a toolbar composed alongside is suppressed while this is on, because there is nothing yet to choose a window from.
value labels
Printing the numbers on the marks, and how few of them to print. A number on every point is the most common way a chart is spoiled — the labels compete with the shape they annotate and the reader loses both — so the default is last, the one value a reader would otherwise trace back to the axis for. extremes is what "which month was worst" actually asks; all earns its place only where five or six bars and their exact figures are the whole point, past which it is a table wearing a chart.
| Desktop |
|---|
| 1,860 |
| 30,500 |
| 23,700 |
| 27,300 |
| 9,200 |
| 31,400 |
sonify
Two series on screen and one worth listening to. keys narrows the run, because playback is sequential — each series plays in full, one after another — and a listener rarely wants all of them. formatValue is what the announcement speaks, so the range is read out in the units the axis prints, "from $9,200 to $31,400" rather than "9200"; leave it off and the spoken range falls back to the default tick format, which is not the one on the axis beside it.
| month | Revenue | Refunds |
|---|---|---|
| Jan | 18,600 | 1,240 |
| Feb | 30,500 | 980 |
| Mar | 23,700 | 2,310 |
| Apr | 27,300 | 1,150 |
| May | 9,200 | 3,040 |
| Jun | 31,400 | 870 |
toolbar
The toolbar tops out at five controls and ships no overflow menu, which is a size decision as much as a design one: a row that can never outgrow five 44px targets does not need one, and the menu would cost every consumer of every cartesian chart the Radix menu the chart reaches statically. A chart in a narrow card drops controls instead — zoom={false}, or one export format rather than two. The two files are deliberately not the same picture: the PNG is the plot as it stands and follows the zoom, while the CSV is always the whole dataset, like the figure's hidden data table, because a spreadsheet quietly missing the rows you had zoomed past is data loss you cannot see.
Arrow keys pan by one point, Page Up and Page Down by a screenful, Home and End jump to the ends. Plus and minus zoom, zero resets. Ctrl and the wheel zoom around the pointer, and dragging across the plot zooms to that span.
Showing Jan 25 to Dec 26, 24 of 24 points
| month | Desktop |
|---|---|
| Jan 25 | 1,200 |
| Feb 25 | 1,534 |
| Mar 25 | 1,837 |
| Apr 25 | 2,077 |
| May 25 | 2,235 |
| Jun 25 | 2,296 |
| Jul 25 | 2,258 |
| Aug 25 | 2,131 |
| Sep 25 | 1,932 |
| Oct 25 | 1,687 |
| Nov 25 | 1,428 |
| Dec 25 | 1,189 |
| Jan 26 | 999 |
| Feb 26 | 884 |
| Mar 26 | 861 |
| Apr 26 | 937 |
| May 26 | 1,108 |
| Jun 26 | 1,360 |
| Jul 26 | 1,669 |
| Aug 26 | 2,005 |
| Sep 26 | 2,337 |
| Oct 26 | 2,631 |
| Nov 26 | 2,861 |
| Dec 26 | 3,004 |
Types
export type BarVariant = 'default' | 'hatched' | 'duotone' | 'duotone-reverse' | 'gradient' | 'stripped'
export type BarStackType = 'default' | 'stacked' | 'percent'
export type BarOrientation = 'vertical' | 'horizontal'Accessibility
- title is required; the rows are also rendered as a visually hidden table.
- Every bar carries an invisible full-height hit rectangle, so a 3px bar at the bottom of the scale is as easy to hit as a full-height one.
- A clickable legend entry is a real button with aria-pressed, not a div with a click handler.
- The staggered grow-in is anchored to the chart’s own start rather than to each bar’s mount, so a hover cannot replay it — and reduce-motion drops it entirely.