DocumentationComponentsTheme Creator
Theme CreatorGitHubIntroduction
InstallationUsageThemingMotion
Components
ChartArea ChartBar ChartLine ChartLive Line ChartComposed ChartPie ChartFunnel ChartRadar ChartRadial ChartSankey ChartScatter ChartCandlestick ChartSunburst ChartChoropleth ChartActivity Heatmap
AI agentsContributing
  1. Components
  2. Bar Chart

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.

$ pnpm add @tessinaui/ui

When to use

  • 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 no sort prop because Array.prototype.sort already 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.

When not to use

  • For a single value against a maximum. Use Progress or Meter.
  • For a value changing continuously over time. Use LineChart or AreaChart, which show the trend rather than separate totals.
  • For parts of one whole with no time axis. Use PieChart.
  • For ordered stages of one pipeline. Use FunnelChart, which prints the conversion rate between stages.
  • For a handful of exact figures. A Table reads faster than bars when the reader needs the numbers, not the shape.
  • For many series. Past the six chart colours, group into "other", split into small multiples or label bars directly rather than adding colours.
  • For bars read against a line. A total with its average, target or previous period drawn as a line over it is a ComposedChart.

Installation

Terminal
pnpm add @tessinaui/ui recharts

Usage

example.tsx
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"
/>

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

barColor returns a fill per datum, so colour can encode meaning (over budget) rather than series identity. Say the same fact in words beside the chart, so it never rests on colour alone. Colour is not a way to show a choice: for a bar a reader picks, by pointer, touch or keyboard, use selectionMode (below).

Choosing a category

With selectionMode="single" a click, a tap, or Enter or Space on the category the arrow keys are on chooses it, and choosing it again clears it. A choice is the whole category: every bar of its group or stack wears one ring, its label on the axis turns into a chip, and the other categories step back to a wash inside an outline. Here the table below lists the chosen category's transactions, and the chip above it clears the choice.

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

On a coloured plate

Inside a coloured Surface the chart follows the plate with no prop: value labels, tick text, grid lines, the hover band, the legend and the focus ring take the plate's ink, and a track stops at the 8% well so a label above a bar stays legible. Series colours are yours. The --chart-N tokens are measured against the page, not a plate, so give a series the plate's ink (hsl(var(--surface-ink))). A label inside a bar then reads in the plate's own colour. In a stack, give the second series 80% of the ink; the join between segments is drawn in the plate's colour. The tooltip is a page panel, so it keeps the page's look.

States

Loading, empty and error side by side.

API Reference

BarChart

PropTypeDefaultDescription
dataBarChartDatum[]—Row-per-category data.
configChartConfig—Series metadata (label, color).
categoryKeystring—Datum key for the category axis. Warns in development if it is not a key of the first row.
seriesstring[]all config keysWhich series to plot.
labelstring—Accessible name; renders the chart as a role="figure".
descriptionstring—Visually hidden summary for assistive tech.
orientation"vertical" | "horizontal""vertical"Columns vs sideways bars.
stackedboolean | "normalized"falseGrouped, 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).
showTrackbooleanfalseFull-length neutral rail behind each bar.
minBarLengthnumber0Minimum drawn length in px, so a zero still renders.
maxBarWidthnumber—Cap on bar thickness in px.
barGapnumber4Gap between bars within a category group.
categoryGapnumber | string"10%"Gap between category groups.
showGridbooleantrueGridlines, always perpendicular to the bars.
showCategoryAxisbooleantrueThe category axis.
showValueAxisbooleanfalseThe numeric axis.
categoryAxisWidthnumber | "auto"estimatedCategory gutter width for horizontal charts.
categoryLabelAnglenumber—Rotate category tick labels (degrees).
categoryTickIntervalnumber | "preserveStartEnd" | …—Draw every n-th tick; decouples label count from bar count.
showZeroLinebooleanfalseEmphasise the zero baseline.
showLegendbooleantrueThe legend.
legendPosition"top" | "bottom""bottom"Legend placement.
hiddenSeriesstring[]—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, with the series it belongs to. Pointer only: for a choice the keyboard reaches too, use selectionMode.
selectionMode"none" | "single""none""single" lets a reader choose one category by click, tap, Enter or Space; choosing it again clears it.
selectedstring | null—categoryKey value of the chosen category, controlled. null chooses nothing.
defaultSelectedstring | nullnullCategory chosen at first render, uncontrolled.
onSelectedChange(key, { datum, index, source }) => void—Called on every choice and clear; source is "pointer" or "keyboard".
activestring | null—Category lit from outside the chart, controlled — the tooltip opens on it.
onActiveChange(key: string | null) => void—Called when the chart's own pointer or arrow keys move to another category.
stringsPartial<BarChartStrings>—Selection's words per entry: selected, cleared, items, category. Arabic under dir="rtl".
showLabelsbooleanfalseRender 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.
showTooltipbooleantrueThe tooltip.
dir"ltr" | "rtl"inheritedReading direction. Unset, read off the chart's position in the page once it mounts.
loadingbooleanfalseRender the skeleton, sized to the data.
errorReactNode—Render an error message with the error intent.
emptyStateReactNode"No data to display"Shown when data is empty.
childrenReactNode—Extra Recharts elements drawn in the plot (ReferenceLine, ReferenceArea, …).

Data attributes

AttributeOnValue
data-component="bar-chart"rootAlways. The base keeps data-slot="chart" on the same element, so target this to distinguish bar from the other five chart types.
data-chartrootGenerated chart id, used to scope the per-series CSS variables.
data-slot="chart-tooltip"tooltipWhile a tooltip is shown.
data-slot="chart-legend" / chart-legend-itemlegendLegend items are real aria-pressed buttons when onSeriesToggle is set.
data-slot="chart-skeleton"rootWhile loading. Also aria-busy="true".
data-slot="chart-message"rootWhile error or empty.
data-chart-itemeach bar, while the chart selects or is litAlso data-category-index.
data-selected / data-activethe chosen / lit category's bars—
data-slot="chart-selection-ring"the chosen category's ring—
data-slot="chart-selection-tick-chip"the chosen category's axis label—
data-slot="chart-announcer"the status regionWhile the chart selects.

Accessibility

VoiceOver + Safari: passed 2026-09-14. A static read gives "Revenue more than doubled across the year, figure", then the hidden summary, "Quarterly revenue in thousands of dollars: Q1 420, Q2 510, Q3 680, Q4 910." Tab lands on the plot, which VoiceOver reads after the figure name as "web application", and the arrow keys read each point through the tooltip ("Q2, Revenue, 510"). NVDA and JAWS are untested.

  • label is the only way to name the chart. It renders the container as a role="figure" region with that accessible name. An aria-label on 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.
  • description renders 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. With orientation="horizontal" the categories run top to bottom, so ↓ and ↑ step through them as well. A key pressed with a modifier stays the browser's, and an onKeyDown that prevents the default opts out.
  • With selectionMode="single" the categories become a list to choose from. The chart's one tab stop is a named listbox ("Spending: Categories"), each category an option with aria-selected and a name that carries every series' value ("Groceries, Spent $273"), and the category the arrow keys are on is its aria-activedescendant. Enter or Space chooses it, so Enter no longer toggles the tooltip; Escape still hides it. A polite status region says "Groceries selected" or "Selection cleared" once per change. The screen reader pass of this mode is still to be run.
  • 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 minBarLength when a zero is a meaningful value rather than missing data.
  • Colour is never the only channel. Pair barColor with a label, an ordering, or text near the chart.

Agent notes

  • Name the chart with label, not aria-label. aria-label reaches the root element but the root has no role, so assistive tech ignores it. This is the single most common mistake with this component.
  • categoryKey must be a key of the data rows. A typo renders a chart with a blank category axis and warns in development.
  • stacked takes true or "normalized" — there is no percent value.
  • rounded is a token scale (none…full), not a pixel number. There is no radius prop; earlier documentation was wrong about this.
  • To let a reader choose a category, use selectionMode="single" with selected / onSelectedChange — a key is the categoryKey value, the whole group or stack. onBarClick is pointer only. To colour one bar for what it means, return a colour from barColor. There is no activeIndex prop.
  • To sort or cap the categories, transform data before passing it.

Theming

ElementToken
Bar fill--chart-1 … --chart-6 (via config[key].color)
Track railcurrentColor at 10%, 8% on a coloured plate
Stacked segment separator--surface-fill, the colour behind the chart
Grid + axis linesmuted-foreground/20 — not border, which collapses into the chart surface in dark mode
Axis tick labelsmuted-foreground
Value labelsforeground outside the bar, background inside it; on a coloured plate, --surface-ink outside and --surface-on-ink inside
Tooltip surfacepopover / popover-foreground / border
Chosen category's ringforeground over a gap of --surface-fill; --surface-ink on a coloured plate
Chosen category's axis labelthe inverse chip: foreground under background text; --surface-ink under --surface-on-ink on a plate
Categories stepped back30% of their fill inside a full-strength outline of it

Migration

  • dir no longer defaults to "ltr". Unset, the chart reads the direction it inherits from the page, so it mirrors on an RTL page. Pass dir="ltr" to keep the old layout there.
  • radius never existed. Earlier docs listed radius: number. The real prop is rounded, taking none | sm | md | lg | full, default md.
  • 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.
  • error renders with the error intent instead of neutral chrome.
Area ChartLine Chart

On this page

When to useWhen not to useInstallationUsageExamplesDefaultGroupedHorizontalStackedNormalized (100% stacked)Ranking with tracksLabelsPer-bar colourChoosing a categoryNegative valuesValue formattingInteractive legendTarget and reference linesMinimalIn a narrow cardAccessibilityOn a coloured plateStatesAPI ReferenceBarChartData attributesAccessibilityAgent notesThemingMigration