Bar Chart
Token-driven bar chart built on Recharts v3 — vertical columns or horizontal bars, grouped, stacked or normalized to 100%, with tracks, per-bar colour, value formatting and a named accessible figure.
Playground
Installation
pnpm add @tessinaui/ui rechartsUsage
import { BarChart } from "@tessinaui/ui";
import type { ChartConfig } from "@tessinaui/ui";
const config: ChartConfig = {
desktop: { label: "Desktop", color: "var(--chart-1)" },
mobile: { label: "Mobile", color: "var(--chart-2)" },
};
const data = [
{ month: "Jan", desktop: 186, mobile: 80 },
{ month: "Feb", desktop: 305, mobile: 200 },
];
<BarChart
data={data}
config={config}
categoryKey="month"
label="Desktop traffic grew 64% in February"
/>When to use one
- Comparing a value across categories is what a bar chart is for. Bars encode length, which the eye compares more accurately than angle or area.
- Long category names? Use
orientation="horizontal". Vertical bars have to rotate or drop their labels; horizontal bars give each one a full line. - Ranking? Sort the rows before you pass them and use
showTrack— a rail behind each bar makes "share of the leader" readable. There is nosortprop becauseArray.prototype.sortalready exists. - Composition rather than volume?
stacked="normalized"fills every bar to 100%, so the chart reads as proportion. - Below
md, prefer stacked or small multiples over grouped. Grouped bars turn into unreadable hairlines at phone width — in a survey of 184 shipped mobile screens, grouped bars appeared exactly once. - Not a bar chart: a single value against a maximum is
ProgressorMeter; a value changing continuously over time isLineChartorAreaChart; parts of one whole with no time axis isPieChart.
Examples
Default
A single-series bar chart with upright columns.
Grouped
Multiple series side by side, one group per category. maxBarWidth keeps a
few-category chart from drawing slabs.
Horizontal
Bars run sideways, which is the right choice for long category labels.
Stacked
Multiple series combined into one bar per category. Only the outermost segment rounds — a rounded join inside a stack reads as a notch — and adjacent segments are separated by a hairline in the chart surface colour, because chart colours are only required to contrast with the background, not with each other.
Normalized (100% stacked)
stacked="normalized" fills every bar to the full axis, so the chart reads
composition instead of volume. The value axis formats itself as percentages.
Ranking with tracks
A neutral rail behind each bar, sorted rows, labels inside the bar end, and a
minBarLength stub so a zero still reads as "zero" rather than "no data".
Labels
Each bar's numeric value rendered against the bar. labelPosition moves it
outside the growing end, inside either end, or to the centre.
Per-bar colour and selection
barColor returns a fill per datum, so colour can encode meaning (over budget)
rather than series identity. The same mechanism mutes the bars you did not
select — there is no separate selection prop.
Negative values
Bars below zero grow downward, round their lower edge and place their labels
outside that end. showZeroLine draws the baseline they cross.
Value formatting
One valueFormatter feeds the value axis, the bar labels and the tooltip, so a
number cannot be spelled three different ways in the same chart.
Interactive legend
Legend interactivity is controlled: the chart never hides a series on its own. Hidden series stay in the legend as unpressed toggles, so they can be switched back on.
Target and reference lines
Reference lines, areas and dots compose as children. There is no prop per annotation kind, so anything Recharts can draw inside a plot is reachable.
Minimal
No value axis, no grid, no legend — bar height plus a sparse category axis carry
the whole reading. categoryTickInterval decouples label count from bar count.
In a narrow card
A chart lives in whatever column a consumer gives it, and a 280px card is a tighter constraint than most phones. Drop the chrome the column cannot afford.
Accessibility
States
Loading, empty and error side by side.
Accessibility
labelis the only way to name the chart. It renders the container as arole="figure"region with that accessible name. Anaria-labelon the root is silently ignored — the root carries no role for a label to attach to.- Say what the chart shows, not what it is. "Revenue more than doubled across the year" beats "Bar chart of revenue by quarter"; the reader can already tell it is a bar chart.
descriptionrenders a visually hidden summary inside the figure, so a static read carries the numbers without stepping through the bars.- Keyboard: Tab moves to the chart,
←/→step through categories and move the tooltip, and Escape dismisses the tooltip without moving focus (WCAG 1.4.13). Stepping again brings it back — dismissal is per reading. - Contrast: bars are held to 3:1 against the surface and tick text to 4.5:1, with gridlines exempt. Adjacent stacked segments carry a surface-coloured hairline, because chart colours are not required to contrast with each other.
- A zero-height bar cannot be seen, hovered or focused. Set
minBarLengthwhen a zero is a meaningful value rather than missing data. - Colour is never the only channel. Pair
barColorwith a label, an ordering, or text near the chart.
API Reference
BarChart
| Prop | Type | Default | Description |
|---|---|---|---|
data | BarChartDatum[] | — | Row-per-category data. |
config | ChartConfig | — | Series metadata (label, color). |
categoryKey | string | — | Datum key for the category axis. Warns in development if it is not a key of the first row. |
series | string[] | all config keys | Which series to plot. |
label | string | — | Accessible name; renders the chart as a role="figure". |
description | string | — | Visually hidden summary for assistive tech. |
orientation | "vertical" | "horizontal" | "vertical" | Columns vs sideways bars. |
stacked | boolean | "normalized" | false | Grouped, stacked, or stacked to 100%. |
rounded | "none" | "sm" | "md" | "lg" | "full" | "md" | Corner rounding of the bar's growing end. full means a pill — both ends round. |
size | "sm" | "md" | "lg" | "md" | Aspect ratio (3/1, 2/1, 16/9). |
showTrack | boolean | false | Full-length neutral rail behind each bar. |
minBarLength | number | 0 | Minimum drawn length in px, so a zero still renders. |
maxBarWidth | number | — | Cap on bar thickness in px. |
barGap | number | 4 | Gap between bars within a category group. |
categoryGap | number | string | "10%" | Gap between category groups. |
showGrid | boolean | true | Gridlines, always perpendicular to the bars. |
showCategoryAxis | boolean | true | The category axis. |
showValueAxis | boolean | false | The numeric axis. |
categoryAxisWidth | number | "auto" | estimated | Category gutter width for horizontal charts. |
categoryLabelAngle | number | — | Rotate category tick labels (degrees). |
categoryTickInterval | number | "preserveStartEnd" | … | — | Draw every n-th tick; decouples label count from bar count. |
showZeroLine | boolean | false | Emphasise the zero baseline. |
showLegend | boolean | true | The legend. |
legendPosition | "top" | "bottom" | "bottom" | Legend placement. |
hiddenSeries | string[] | — | Series to hide. They stay in the legend as unpressed toggles. |
onSeriesToggle | (key: string) => void | — | Called when a legend item is clicked. |
onBarClick | (datum, index, seriesKey) => void | — | Called when a bar is clicked. |
showLabels | boolean | false | Render each bar's value as a label. |
labelPosition | "outside" | "inside-start" | "inside-end" | "center" | "outside" | Where the value label sits. |
valueFormatter | (value, seriesKey) => string | number | — | Formats values in labels, the tooltip and the value axis. |
categoryFormatter | (value: string) => string | — | Formats category tick labels; truncation lives here. |
barColor | (datum, index, seriesKey) => string | undefined | — | Per-datum fill. Return undefined to keep the series colour. |
showTooltip | boolean | true | The tooltip. |
dir | "ltr" | "rtl" | "ltr" | Reading direction. |
loading | boolean | false | Render the skeleton, sized to the data. |
error | ReactNode | — | Render an error message with the error intent. |
emptyState | ReactNode | "No data to display" | Shown when data is empty. |
children | ReactNode | — | Extra Recharts elements drawn in the plot (ReferenceLine, ReferenceArea, …). |
Data attributes
| Attribute | On | Value |
|---|---|---|
data-component="bar-chart" | root | Always. The base keeps data-slot="chart" on the same element, so target this to distinguish bar from the other five chart types. |
data-chart | root | Generated chart id, used to scope the per-series CSS variables. |
data-slot="chart-tooltip" | tooltip | While a tooltip is shown. |
data-slot="chart-legend" / chart-legend-item | legend | Legend items are real aria-pressed buttons when onSeriesToggle is set. |
data-slot="chart-skeleton" | root | While loading. Also aria-busy="true". |
data-slot="chart-message" | root | While error or empty. |
Agent notes
- Name the chart with
label, notaria-label.aria-labelreaches the root element but the root has no role, so assistive tech ignores it. This is the single most common mistake with this component. categoryKeymust be a key of the data rows. A typo renders a chart with a blank category axis and warns in development.stackedtakestrueor"normalized"— there is nopercentvalue.roundedis a token scale (none…full), not a pixel number. There is noradiusprop; earlier documentation was wrong about this.- To highlight one bar, return a colour from
barColorfor it and a muted colour for the rest. There is noactiveIndexprop. - To sort or cap the categories, transform
databefore passing it.
Theming
| Element | Token |
|---|---|
| Bar fill | --chart-1 … --chart-6 (via config[key].color) |
| Track rail | currentColor at 10% |
| Stacked segment separator | --chart-surface |
| Grid + axis lines | muted-foreground/20 — not border, which collapses into the chart surface in dark mode |
| Axis tick labels | muted-foreground |
| Value labels | foreground outside the bar, background inside it |
| Tooltip surface | popover / popover-foreground / border |
Migration
radiusnever existed. Earlier docs listedradius: number. The real prop isrounded, takingnone | sm | md | lg | full, defaultmd.rounded="full"now renders a pill (both ends round). It previously rounded only the growing end, which domed a short wide bar rather than producing the pill shape the value implies.- Stacked bars round only their outermost segment. Every segment used to round, which put an arc inside the stack.
- The skeleton now draws one placeholder bar per data row instead of a fixed seven.
errorrenders with the error intent instead of neutral chrome.