Charts · Column Chart

Column Chart

Vertical bars for ordered categories – time buckets or rankings. Data-ink discipline (Tufte): one accent, no gridlines, monospace numerics, direct value labels by default, built-in source attribution, and accessible empty / loading / error states.

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 { ColumnChart } from "@/components/charts/column-chart";

const data = [142, 168, 187, 159, 203, 178, 215];
const labels = ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"];

export function Example() {
  return (
    <ColumnChart
      data={data}
      labels={labels}
      height={240}
      gap={4}
      header={{ title: "Active users", subtitle: "Last 7 days" }}
      trend={0.184}
      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="active-users-7d"
    />
  );
}
Chart
Active users
Last 7 days
Source: Brock Analytics, 2026
Column chart with 7 data points. Highest: SUN (215); lowest: MON (142). Source: Brock Analytics, 2026.
Data table.
LabelValue
MON142
TUE168
WED187
THU159
FRI203
SAT178
SUN215

Installation

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

Usage

import { ColumnChart } from "@/components/charts/column-chart";

const data = [142, 168, 187, 159, 203, 178, 215];
const labels = ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"];

export function Example() {
  return (
    <ColumnChart
      data={data}
      labels={labels}
      height={220}
      trend={0.184}
      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 (
    <ColumnChart
      data={data}
      labels={labels}
      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 annotations/focusBar (defaults to label); meta = your payload, returned untouched in every callback; negatives render below the zero baseline. The synthetic 'Other' bar carries isOther + items[] (output-only)
sort'none' | 'asc' | 'desc''none'Reorder bars by value (stable). 'none' preserves input order – the honest default for time buckets; asc/desc turns the chart into a ranking
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
labelsstring[]undefinedX-axis labels (rendered in Hack mono under bars + in hover tooltip). Only used when data is number[]
heightnumber200Chart height in pixels (Y-axis + bars area)
gapnumber4Gap between bars in pixels. Auto-shrinks for dense datasets (60+ bars)
accentstringvar(--brock-accent)Override the bar fill color (any CSS color or var). Defaults to Brock orange
barRadiusnumber0Top-corner radius in px. Common values: 0 (sharp), 2 (subtle), 6 (rounded)
header{ title?, subtitle? }undefinedTitle + subtitle block above the chart
xAxis{ title?, hideTicks? }undefinedX-axis configuration (title below ticks, hide tick labels)
yAxis{ title?, max?, hideTicks? }undefinedY-axis configuration. There is deliberately no min – a column 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
numberFormat{ prefix?, suffix?, decimals?, locale?, notation?, style?, currency? }undefinedNumber formatter applied to Y-axis, tooltip, and data labels. Supports BCP-47 locale, Intl.NumberFormat notation ('compact' → 1.2K), style ('currency' / 'percent'), and ISO 4217 currency. Explicit formatValue/yAxisFormat win
dataLabels{ show?: boolean | 'auto', format? }{ show: 'auto' }Direct value labels at each bar's outer end (Hack mono; mirrored below negative bars). 'auto' (the default) shows labels AND hides the Y axis when the chart has <= 8 bars – redundant ink once every value is printed. Explicit yAxis.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 historical/estimated/in-progress without spending a second color (Tufte)
hatchUntilIndexnumberundefinedConvenience: bars with INPUT index < N render hatched (applied before sort/topN – the pattern travels with its datum). Classic historical-vs-projected encoding
hatchFromIndexnumberundefinedMirror of hatchUntilIndex: bars with index >= N render hatched. Useful for forecast bands and 'last N hatched' patterns. Combinable with hatchUntilIndex (union)
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 bars don't fit. 'auto' enables horizontal scroll; Y-axis pins to the left while bars + X-axis scroll together
minBarWidthnumber4Minimum px per bar. Used with scroll='auto' to decide chart min-width: N*minBarWidth + (N-1)*gap. Ignored when scroll='none'
bands{ from, to, label?, color? }[]undefinedPlot bands – highlighted vertical zones over a range of DISPLAY positions. Editorial pattern ('Q3', 'deployment window'). Render behind bars at low opacity; indices clamp to the data range. Bands assume input order – combining bands with sort dev-warns (a 'Q3' zone is meaningless after re-ranking)
trendnumberundefinedDecimal trend indicator e.g. 0.184 → ↗ +18.4%. Orange if positive, muted if negative
referenceLine{ value: number | { stat: 'mean' | 'median' }, label? }undefinedDashed reference line – a fixed threshold ('Q3 target') 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)
animation{ enabled?, duration? }{ enabled: true, duration: 400 }Staggered bar-rise on mount. Disabled automatically when prefers-reduced-motion is set
loadingbooleanfalseLoading state. With no data → a full skeleton (solid placeholder bars + 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). Useful for analytics or custom share flows
refRef<ColumnChartHandle>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
slotsColumnChartSlots{}Headless slot dictionary. Each slot replaces a default sub-component: tooltip (per-bar), empty (no data), loading (skeleton), error (terminal), toolbar (export chips), caption (below source), watermark (figure overlay). Each slot receives typed props. Slots win over loadingFallback / errorFallback shortcuts
captionstringundefinedShort editorial caption – italic muted text with left border, rendered below the source line. FT/Stripe-Letters print-margin pattern. slots.caption wins over this
watermarkstringundefinedDiagonal watermark text – faint pixel-font overlay (≈6% opacity) over the chart. A document-lifecycle marker (DRAFT, CONFIDENTIAL) – not branding (use source for attribution). Deliberately KEPT in print: a confidential paper report must carry its marking. slots.watermark wins over this
annotationsColumnChartAnnotation[]undefinedFree-floating editorial annotations at (x, y) in data space. x: number = INPUT index (travels with its datum through sort/topN; dropped with a dev warning if that datum collapses into 'Other') or string = key/label match. y may be negative. { x, y, text, anchor?, arrow?, color? }; dashed connector optional. Reproduced in the SVG / PNG export
chartTypestring'column'Machine-readable identifier stamped on the figure as data-chart-type. Included in toJSON() output. AI/LLM tooling + analytics use it to reason about the chart shape
dataDescriptionstringundefinedNatural-language description of the data ('Daily active users, last 7 days'). Stamped as data-description. For AI prompts and editorial provenance. Distinct from `description` (which is the screen-reader auto-label)
data-testidstringundefinedQA selector hook forwarded to the figure. Stable across className refactors – Testing Library / Playwright convention
descriptionstringauto-generatedAccessible description for screen readers (figcaption + table caption). Defaults to 'Column chart with N data points. Source: ...'
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
yAxisFormat(v: number) => stringtoLocaleStringFormat function for Y-axis tick labels. Wins over numberFormat

Accessibility

Built to WCAG 2.2 AA. Keyboard navigable, screen-reader friendly, honors prefers-reduced-motion.

Keyboard
TabMove focus into the chart (single tab stop)
← → ↑ ↓Navigate between bars (roving tabindex)
HomeJump to first bar
EndJump to last bar
Screen reader markup
  • · <figure role="figure"> wraps the chart with an aria-labelledby pointing to the figcaption
  • · Each bar uses role="graphics-symbol" with aria-label="LABEL: value"
  • · A visually-hidden <table class="sr-only"> provides a tabular data summary (caption + rows for each data point)
  • · Trend indicator gets a human-readable label (“Trend up 18.4 percent”) – arrows are aria-hidden

Design moves

  1. 1. Monospace Y-axis numbers + tabular-nums (Hack font) – values align by digit width.
  2. 2. Single --brock-accent (orange) for all bars – both sides of zero. No gradient, no glow, no palette coding; per-bar color is reserved for single editorial exceptions (the anomaly, the current period).
  3. 3. No gridlines. Single 1px baseline at zero (Tufte data-ink).
  4. 4. Hover-tooltip: period label in Geist sans + value in Hack mono, in a single elevated card.
  5. 5. Staggered entry animation (30ms cascade, scale-Y from baseline). Disabled on prefers-reduced-motion.
  6. 6. Built-in source prop renders FT/Bloomberg-style attribution line below the chart.
  7. 7. A clear empty state (icon + No data message) when the dataset is empty; a full state machine via loading / error (solid skeleton, refresh overlay with a spinner, retry) in the same visual language.

When to use

Column Chart fits ordered categories where each bar is a discrete bucket: temporal (hours, days, weeks, agent calls per minute) or ranked (traffic by channel via sort + topN, revenue by region, profit/loss by month – negatives are first-class). Best for showing volume, count, or activity rhythm at a glance with sparse axes that don’t compete with the data.

When not to use

For continuous trends use Line Chart. For tiny embedded charts inside metric cards or prose use Sparkline. For categorical ranking with long labels (horizontal bars) use Bar Chart (coming soon). For composition over time use Stacked Column Chart (coming soon).

Inspired by

  • · Financial Times Visual Journalism – sparse axes, source-line attribution, single-color bars
  • · Stripe Annual Letters – inline numerics in editorial flow
  • · The Pudding – interactive storytelling with restraint
  • · Edward Tufte, The Visual Display of Quantitative Information – data-ink discipline