Bar Chart
Horizontal bars for ranked categories – the ranking shape. Long category labels read horizontally (the reason bar charts exist); height derives from the data; negatives grow to the left of an always-visible zero baseline. Same Tufte discipline as Column Chart: one accent, no gridlines, direct value labels by default.
Studio · Code, chart, settings
A three-panel workbench. Tweak any setting on the right – the chart in the middle updates live, and the code on the left regenerates, ready to paste into your app.
import { BarChart } from "@/components/charts/bar-chart";
const data = [
{ label: "DIRECT", value: 4120 },
{ label: "SEARCH", value: 3870 },
{ label: "SOCIAL", value: 1290 },
{ label: "EMAIL", value: 940 },
{ label: "REFERRAL", value: 620 },
{ label: "PARTNER", value: 480 }
];
export function Example() {
return (
<BarChart
data={data}
header={{ title: "Traffic by channel", subtitle: "Last 30 days" }}
source="Brock Analytics, 2026"
onBarClick={(point, index) => {
console.log("clicked", index, point);
}}
onBarHover={(point, index) => {
// point is null on mouse leave
setHoverIndex(index);
}}
onBarFocus={(point, index) => {
// Fires on keyboard navigation
announce(`Bar ${index + 1}: ${point.value}`);
}}
exportable
exportFileName="traffic-by-channel"
/>
);
}Installation
npx shadcn@latest add https://brockui.com/r/bar-chartUsage
import { BarChart } from "@/components/charts/bar-chart";
const data = [
{ label: "DIRECT", value: 4120 },
{ label: "SEARCH", value: 3870 },
{ label: "SOCIAL", value: 1290 },
{ label: "EMAIL", value: 940 },
];
export function Example() {
return (
<BarChart
data={data}
sort="desc"
source="Brock Analytics, 2026"
/>
);
}
// Opinionated defaults — but every sub-component is yours to replace via the
// `slots` prop. The data is passed in; you control the rendering.
export function WithCustomTooltip() {
return (
<BarChart
data={data}
slots={{
tooltip: ({ label, value }) => (
<div className="border-2 border-brock-accent p-2">
<span className="font-mono tabular-nums">{value}</span> · {label}
</div>
),
}}
/>
);
}Props
| Name | Type | Default | Description |
|---|---|---|---|
| data | number[] | DataPoint[] | — | Bar values. Two forms: number[] (with labels prop) or { key?, label?, value, meta?, pattern?, color?, highlight?, note? }[] (object form). key = stable address for focusBar (defaults to label); meta = your payload, returned untouched in every callback; negatives grow LEFT of the zero baseline. The synthetic 'Other' bar carries isOther + items[] (output-only) |
| labels | string[] | undefined | Category labels (left column + tooltip). Only used when data is number[] |
| barThickness | number | 24 | Height of each bar in pixels (default 24 – the WCAG 2.5.8 pointer-target floor). Together with gap it DERIVES the chart height: there is no height prop, the data decides |
| gap | number | 8 | Vertical gap between bars in pixels (default 8) |
| maxHeight | number | undefined | Cap on the bars-area height. When the derived height exceeds it AND scroll='auto', rows scroll vertically in a keyboard-focusable container. Without scroll='auto' it is a documented no-op – every category stays visible |
| labelWidth | number | 96 | Width of the left category-label column in pixels (default 96). Labels truncate with ellipsis – full text lives in the tooltip and sr-table; in narrow containers (≤420px) the column clamps, ≤240px it hides (CSS-only, canon §11) |
| sort | 'none' | 'asc' | 'desc' | 'none' | Reorder bars by value (stable). 'none' preserves input order; desc/asc turn the chart into a ranking – the bar chart's natural mode |
| topN | number | { n, label?, pinned?, distinct? } | undefined | Keep the N largest bars, roll the tail into one 'Other' aggregate (summed). Defaults: pinned last regardless of sort, muted --brock-other fill. Callbacks receive isOther + the collapsed items[]. Number shorthand = all defaults |
| referenceLine | { value: number | { stat: 'mean' | 'median' }, label? } | undefined | Dashed VERTICAL reference line – a fixed threshold ('Plan') or a computed statistic over the ORIGINAL input data (sort/topN must not move a statistic). Stats auto-label Mean/Median. Participates in the scale on both sides, so negative and break-even (0) references stay visible |
| source | string | undefined | Attribution line rendered below the chart (FT pattern) |
| accent | string | var(--brock-accent) | Override the bar fill color (any CSS color or var). Defaults to Brock orange |
| barRadius | number | 0 | Outer-corner radius in px. Baseline-side corners stay flat; positive bars round their RIGHT corners, negative bars their LEFT |
| header | { title?, subtitle? } | undefined | Title + subtitle block above the chart |
| xAxis | { title?, max?, hideTicks? } | undefined | X-axis (VALUE axis) configuration. There is deliberately no min – a bar chart's baseline is always zero (truncated bars lie). max is extend-only headroom: values below the data max are ignored with a dev warning |
| yAxis | { title?, hideLabels? } | undefined | Y-axis (CATEGORY axis) configuration – title, hide labels |
| numberFormat | { prefix?, suffix?, decimals?, locale?, notation?, style?, currency? } | undefined | Number formatter applied to X-axis ticks, tooltip, and data labels. Supports BCP-47 locale, Intl.NumberFormat notation ('compact' → 1.2K), style ('currency' / 'percent'), and ISO 4217 currency. Explicit formatValue/xAxisFormat win |
| dataLabels | { show?: boolean | 'auto', format? } | { show: 'auto' } | Direct value labels at each bar's OUTER end (Hack mono; right of positive, left of negative; deep bars flip the label inside in background color). 'auto' (the default) shows labels AND hides the X-axis ticks when the chart has <= 8 bars. Explicit xAxis.hideTicks wins. format(value, datum) overrides numberFormat |
| pattern | 'solid' | 'hatched' | 'solid' | Default fill pattern for all bars. Per-point pattern on a data point wins over this. Hatched encodes estimated/in-progress without spending a second color (Tufte) |
| patternStyle | 'diagonal' | 'diagonal-reverse' | 'dots' | 'vertical' | 'horizontal' | 'diagonal' | Visual style of hatched bars (chart-level). Per-bar pattern still controls whether a bar is hatched; this controls how each hatched bar looks. Use 'dots' for grayscale/print |
| scroll | 'none' | 'auto' | 'none' | Overflow behavior when the derived height exceeds maxHeight. 'none' (default) – the chart is as tall as the data, every category visible. 'auto' – rows scroll vertically in a keyboard-focusable container |
| animation | { enabled?, duration? } | { enabled: true, duration: 400 } | Staggered bar-grow from the baseline on mount (scaleX; mirrored origin for negatives). Disabled automatically when prefers-reduced-motion is set |
| loading | boolean | false | Loading state. With no data → a full skeleton (solid placeholder rows + an accessible loading label, ARIA role=status). With data → a dim overlay with a small spinner for background refresh. Honors prefers-reduced-motion |
| error | Error | string | null | null | Terminal error state. Replaces the chart even when data is present (stale data next to an error is misleading). Accepts an Error, a string message, or null. ARIA role=alert |
| onRetry | () => void | undefined | Callback for the retry button in the default error state. The button is rendered only when this prop is provided |
| loadingLabel | string | 'Loading…' | Accessible label for the skeleton (loading) state, used as its ARIA label. Override for localization |
| errorLabel | string | 'Error' | Label rendered above the error message and used as the ARIA label. Override for localization |
| retryLabel | string | 'Retry' | Label of the retry button in the default error state. Override for localization |
| loadingFallback | ReactNode | undefined | Full override of the default skeleton + overlay UI. Use for a custom-branded loading experience |
| errorFallback | ReactNode | (error: Error) => ReactNode | undefined | Full override of the default error UI. May be a React node or a function that receives the normalized Error |
| exportable | boolean | { png?, svg?, csv?, copy? } | false | Show the export toolbar (top-right). true = all 4 actions; object form = enable specific ones. Imperative ref methods always work regardless |
| exportFileName | string | (format) => string | 'chart' | Base file name for downloads. String for fixed, function for per-format. Right extension (.png/.svg/.csv) auto-appended |
| onExport | (format, artifact) => void | undefined | Fires after an export completes. Receives format ('png'|'svg'|'csv'|'copy') and the artifact (Blob for png/copy, string for svg/csv) |
| ref | Ref<BarChartHandle> | — | Imperative API: { exportSVG, exportPNG, exportCSV, copyImage, focusBar, getSelection }. Export methods work even in loading/error/empty. focusBar(target) takes a display index (clamped) OR a stable key string (unknown key → -1). getSelection() returns { index (display), key (stable), point } or null |
| onBarClick | (point, index, event) => void | undefined | Fires on click, tap, or Enter/Space on a focused bar. Event is a MouseEvent or KeyboardEvent. Adds cursor-pointer to bars when provided |
| onBarHover | (point | null, index | null) => void | undefined | Fires on mouse enter (point + index) and on leave of the bars area (null, null). Sync custom legends / detail panels with the hovered datum |
| onBarFocus | (point, index) => void | undefined | Fires on keyboard focus changes between bars (arrow keys, Home/End, Tab in, and programmatic focusBar()). Tracks the roving-tabindex position |
| slots | BarChartSlots | {} | Headless slot dictionary: tooltip, empty, loading, error, toolbar, caption, watermark – each receives typed props. Slots win over loadingFallback / errorFallback shortcuts |
| caption | string | undefined | Short editorial caption – italic muted text with a start border, rendered below the source line. FT/Stripe-Letters print-margin pattern. slots.caption wins over this |
| watermark | string | undefined | Diagonal watermark text – faint pixel-font overlay over the chart. A document-lifecycle marker (DRAFT, CONFIDENTIAL) – not branding. Deliberately KEPT in print. slots.watermark wins over this |
| chartType | string | 'bar' | Machine-readable identifier stamped on the figure as data-chart-type (default 'bar'). Included in toJSON() output |
| dataDescription | string | undefined | Natural-language description of the data. Stamped as data-description. For AI prompts and editorial provenance |
| data-testid | string | undefined | QA selector hook forwarded to the figure. Stable across className refactors |
| description | string | auto-generated | Accessible description for screen readers (figcaption + table caption). Defaults to 'Bar chart with N data points. Highest: …; lowest: …' |
| formatValue | (value, datum?) => string | toLocaleString() | Custom value formatter for tooltips, data labels, and the sr-only table. Receives the datum as a second argument (key, meta, isOther…) for context-aware formatting. Wins over numberFormat |
| xAxisFormat | (v: number) => string | toLocaleString | Format function for X-axis (value) tick labels. Wins over numberFormat |
| className | string | undefined | Extra classes on the figure element |
Accessibility
Built to WCAG 2.2 AA. Keyboard navigable, screen-reader friendly, honors prefers-reduced-motion. Same baseline as Column Chart: sr-only data table with transform announcements, forced-colors support, auto description with highest/lowest insight.
| Tab | Move focus into the chart (single tab stop) |
| ↑ ↓ ← → | Navigate between bars (roving tabindex; vertical is the primary axis) |
| Home | Jump to first bar |
| End | Jump to last bar |
Design moves
- 1. Height derives from the data: N bars ×
barThickness+ gaps. No height prop – a ranking never crops its categories. - 2. Category labels in a fixed-width left column (
labelWidth) with an explicit truncation ladder: ellipsis → full text in tooltip + sr-table → container-query clamping. Long labels are WHY bar charts exist. - 3. Single
--brock-accenton both sides of zero; negatives grow left from a vertical 1px baseline. No gridlines (Tufte data-ink). - 4. Direct value labels at the outer end by default (
dataLabels: "auto") – the X axis hides when every value is printed; deep bars flip the label inside in background color. - 5. Vertical reference line (fixed or mean/median over the original input) with its chip at the top.
- 6. Deliberate v1 scope cuts per canon §13: no bands / trend / hatch-index shortcuts (temporal, column-specific) and no free annotations – per-datum
notecovers ranking callouts.
When to use
Ranked categorical comparisons – traffic by channel, revenue by region, contribution by product (negatives are first-class). Reach for Bar over Column whenever category labels are words, not timestamps: horizontal labels read at any length. sort + topN turn raw data into an honest ranking with an explicit 'Other'.
When not to use
For time buckets use Column Chart (time reads left-to-right). For continuous trends use Line Chart. For tiny embedded charts use Sparkline. For part-to-whole composition use Stacked Bar Chart (coming soon).
Inspired by
- · Financial Times Visual Journalism – ranked bars with sparse axes and direct labels
- · Datawrapper – bars over columns whenever labels are words
- · Edward Tufte, The Visual Display of Quantitative Information – data-ink discipline