Charts · Bar Chart

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.

Code
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"
    />
  );
}
Chart
Traffic by channel
Last 30 days
Source: Brock Analytics, 2026
Bar chart with 6 data points. Highest: DIRECT (4,120); lowest: PARTNER (480). Source: Brock Analytics, 2026.
Data table.
LabelValue
DIRECT4,120
SEARCH3,870
SOCIAL1,290
EMAIL940
REFERRAL620
PARTNER480

Installation

npx shadcn@latest add https://brockui.com/r/bar-chart

Usage

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

NameTypeDefaultDescription
datanumber[] | 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)
labelsstring[]undefinedCategory labels (left column + tooltip). Only used when data is number[]
barThicknessnumber24Height 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
gapnumber8Vertical gap between bars in pixels (default 8)
maxHeightnumberundefinedCap 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
labelWidthnumber96Width 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
topNnumber | { n, label?, pinned?, distinct? }undefinedKeep 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? }undefinedDashed 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
sourcestringundefinedAttribution line rendered below the chart (FT pattern)
accentstringvar(--brock-accent)Override the bar fill color (any CSS color or var). Defaults to Brock orange
barRadiusnumber0Outer-corner radius in px. Baseline-side corners stay flat; positive bars round their RIGHT corners, negative bars their LEFT
header{ title?, subtitle? }undefinedTitle + subtitle block above the chart
xAxis{ title?, max?, hideTicks? }undefinedX-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? }undefinedY-axis (CATEGORY axis) configuration – title, hide labels
numberFormat{ prefix?, suffix?, decimals?, locale?, notation?, style?, currency? }undefinedNumber 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
loadingbooleanfalseLoading 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
errorError | string | nullnullTerminal 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() => voidundefinedCallback for the retry button in the default error state. The button is rendered only when this prop is provided
loadingLabelstring'Loading…'Accessible label for the skeleton (loading) state, used as its ARIA label. Override for localization
errorLabelstring'Error'Label rendered above the error message and used as the ARIA label. Override for localization
retryLabelstring'Retry'Label of the retry button in the default error state. Override for localization
loadingFallbackReactNodeundefinedFull override of the default skeleton + overlay UI. Use for a custom-branded loading experience
errorFallbackReactNode | (error: Error) => ReactNodeundefinedFull override of the default error UI. May be a React node or a function that receives the normalized Error
exportableboolean | { png?, svg?, csv?, copy? }falseShow the export toolbar (top-right). true = all 4 actions; object form = enable specific ones. Imperative ref methods always work regardless
exportFileNamestring | (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) => voidundefinedFires after an export completes. Receives format ('png'|'svg'|'csv'|'copy') and the artifact (Blob for png/copy, string for svg/csv)
refRef<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) => voidundefinedFires 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) => voidundefinedFires 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) => voidundefinedFires on keyboard focus changes between bars (arrow keys, Home/End, Tab in, and programmatic focusBar()). Tracks the roving-tabindex position
slotsBarChartSlots{}Headless slot dictionary: tooltip, empty, loading, error, toolbar, caption, watermark – each receives typed props. Slots win over loadingFallback / errorFallback shortcuts
captionstringundefinedShort 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
watermarkstringundefinedDiagonal 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
chartTypestring'bar'Machine-readable identifier stamped on the figure as data-chart-type (default 'bar'). Included in toJSON() output
dataDescriptionstringundefinedNatural-language description of the data. Stamped as data-description. For AI prompts and editorial provenance
data-testidstringundefinedQA selector hook forwarded to the figure. Stable across className refactors
descriptionstringauto-generatedAccessible description for screen readers (figcaption + table caption). Defaults to 'Bar chart with N data points. Highest: …; lowest: …'
formatValue(value, datum?) => stringtoLocaleString()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) => stringtoLocaleStringFormat function for X-axis (value) tick labels. Wins over numberFormat
classNamestringundefinedExtra 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.

Keyboard
TabMove focus into the chart (single tab stop)
↑ ↓ ← →Navigate between bars (roving tabindex; vertical is the primary axis)
HomeJump to first bar
EndJump to last bar

Design moves

  1. 1. Height derives from the data: N bars × barThickness + gaps. No height prop – a ranking never crops its categories.
  2. 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. 3. Single --brock-accent on both sides of zero; negatives grow left from a vertical 1px baseline. No gridlines (Tufte data-ink).
  4. 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. 5. Vertical reference line (fixed or mean/median over the original input) with its chip at the top.
  6. 6. Deliberate v1 scope cuts per canon §13: no bands / trend / hatch-index shortcuts (temporal, column-specific) and no free annotations – per-datum note covers 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