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.
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"
/>
);
}| Label | Value |
|---|---|
| MON | 142 |
| TUE | 168 |
| WED | 187 |
| THU | 159 |
| FRI | 203 |
| SAT | 178 |
| SUN | 215 |
Installation
npx shadcn@latest add https://brockui.com/r/column-chartUsage
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
| 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 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 |
| 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 |
| labels | string[] | undefined | X-axis labels (rendered in Hack mono under bars + in hover tooltip). Only used when data is number[] |
| height | number | 200 | Chart height in pixels (Y-axis + bars area) |
| gap | number | 4 | Gap between bars in pixels. Auto-shrinks for dense datasets (60+ bars) |
| accent | string | var(--brock-accent) | Override the bar fill color (any CSS color or var). Defaults to Brock orange |
| barRadius | number | 0 | Top-corner radius in px. Common values: 0 (sharp), 2 (subtle), 6 (rounded) |
| header | { title?, subtitle? } | undefined | Title + subtitle block above the chart |
| xAxis | { title?, hideTicks? } | undefined | X-axis configuration (title below ticks, hide tick labels) |
| yAxis | { title?, max?, hideTicks? } | undefined | Y-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? } | undefined | Number 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) |
| hatchUntilIndex | number | undefined | Convenience: bars with INPUT index < N render hatched (applied before sort/topN – the pattern travels with its datum). Classic historical-vs-projected encoding |
| hatchFromIndex | number | undefined | Mirror 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 |
| minBarWidth | number | 4 | Minimum 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? }[] | undefined | Plot 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) |
| trend | number | undefined | Decimal trend indicator e.g. 0.184 → ↗ +18.4%. Orange if positive, muted if negative |
| referenceLine | { value: number | { stat: 'mean' | 'median' }, label? } | undefined | Dashed 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 |
| source | string | undefined | Attribution 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 |
| loading | boolean | false | Loading 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 |
| 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). Useful for analytics or custom share flows |
| ref | Ref<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) => 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 | ColumnChartSlots | {} | 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 |
| caption | string | undefined | Short editorial caption – italic muted text with left 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 (≈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 |
| annotations | ColumnChartAnnotation[] | undefined | Free-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 |
| chartType | string | '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 |
| dataDescription | string | undefined | Natural-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-testid | string | undefined | QA selector hook forwarded to the figure. Stable across className refactors – Testing Library / Playwright convention |
| description | string | auto-generated | Accessible description for screen readers (figcaption + table caption). Defaults to 'Column chart with N data points. Source: ...' |
| 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 |
| yAxisFormat | (v: number) => string | toLocaleString | Format 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.
| Tab | Move focus into the chart (single tab stop) |
| ← → ↑ ↓ | Navigate between bars (roving tabindex) |
| Home | Jump to first bar |
| End | Jump to last bar |
- ·
<figure role="figure">wraps the chart with anaria-labelledbypointing to the figcaption - · Each bar uses
role="graphics-symbol"witharia-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. Monospace Y-axis numbers + tabular-nums (Hack font) – values align by digit width.
- 2. Single
--brock-accent(orange) for all bars – both sides of zero. No gradient, no glow, no palette coding; per-barcoloris reserved for single editorial exceptions (the anomaly, the current period). - 3. No gridlines. Single 1px baseline at zero (Tufte data-ink).
- 4. Hover-tooltip: period label in Geist sans + value in Hack mono, in a single elevated card.
- 5. Staggered entry animation (30ms cascade, scale-Y from baseline). Disabled on
prefers-reduced-motion. - 6. Built-in
sourceprop renders FT/Bloomberg-style attribution line below the chart. - 7. A clear empty state (icon +
No datamessage) when the dataset is empty; a full state machine vialoading/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