A bar chart with upright columns or flat bars, grouped, stacked or expanded to 100%, value labels, one color per category, a highlight, negative coloring and sorting, in a frame with a table view, patterns and states.
Revenue and costs
April to September
"use client"
import { ChartBar } from "@/components/ballmac/chart-bar"
const data = [
{ month: "Apr", revenue: 18200, costs: 11400 },
{ month: "May", revenue: 21400, costs: 12100 },
{ month: "Jun", revenue: 19800, costs: 12900 },
{ month: "Jul", revenue: 26300, costs: 13600 },
{ month: "Aug", revenue: 29100, costs: 14200 },
{ month: "Sep", revenue: 34600, costs: 15800 },
]
export default function ChartBarDemo() {
return (
<ChartBar
className="w-full max-w-2xl"
title="Revenue and costs"
description="April to September"
data={data}
xKey="month"
categoryLabel="Month"
series={[
{ key: "revenue", label: "Revenue" },
{ key: "costs", label: "Costs", color: "var(--chart-3)" },
]}
axisFormatter={(v) => `$${v / 1000}k`}
valueFormatter={(v) => `$${v.toLocaleString("en-US")}`}
/>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/chart-barInstall the dependencies.
$ pnpm add recharts@^3Add the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/chart @ballmac/chart-frame @ballmac/i18nCopy the source into your project.
components/ballmac/chart-bar.tsx// Ballmac UI: Chart Bar. https://ui.ballmac.com/components/chart-bar "use client" import * as React from "react" import { Bar, BarChart, CartesianGrid, Cell, LabelList, ReferenceLine, XAxis, YAxis } from "recharts" import { ChartTooltip, ChartTooltipContent } from "@/components/ballmac/chart" import { ChartFrame, describeChart, domainTicks, niceScale, type ChartFrameProps, type ChartRow, type ChartSeries } from "@/components/ballmac/chart-frame" import { useLocale, useMessages } from "@/lib/ballmac/i18n" import { cn } from "@/lib/utils" type ChartBarProps = Omit<ChartFrameProps, "children" | "summaryMode" | "skeleton" | "table"> & { /** "columns" stand upright with categories along the bottom. "bars" lie flat with categories down the side, which reads better for long names. */ layout?: "columns" | "bars" /** "stacked" piles the series up; "expanded" shows each one's share of the total. */ stack?: "none" | "stacked" | "expanded" /** Corner radius of the bars, in pixels. */ radius?: number /** Write each bar's value on it. For one series or grouped bars. */ labels?: boolean /** With one series, "category" colors every bar differently and the legend lists the categories. */ colorBy?: "series" | "category" /** A category to emphasize: the others fade. */ highlight?: string /** With one series, color bars below zero differently. */ negativeColor?: string /** Sort the categories by the first series. */ sort?: "none" | "ascending" | "descending" /** Space between bars. */ gap?: "tight" | "default" | "wide" grid?: "value" | "both" | "none" /** Show the value axis. Hide it when `labels` already write the numbers. */ valueAxis?: boolean /** Writes a tick on the value axis. Defaults to a compact number. */ axisFormatter?: (value: number) => string domain?: [number | "auto", number | "auto"] referenceLines?: { value: number; label: string }[] tooltip?: "dot" | "line" | "dashed" | "none" } const GAP = { tight: 0.08, default: 0.2, wide: 0.45 } as const /** * A bar chart in a ChartFrame: upright columns or flat bars, grouped, stacked or expanded to 100%, value labels, one color per category, * a highlighted category, positive and negative coloring, sorting, reference lines and everything the frame adds: a Table view, patterns, * a toggling legend and loading, empty and error states. */ function ChartBar({ data, xKey, series, layout = "columns", stack = "none", radius = 6, labels = false, colorBy = "series", highlight, negativeColor, sort = "none", gap = "default", grid = "value", valueAxis = true, axisFormatter, domain, referenceLines, tooltip = "dot", summary, categoryFormatter, valueFormatter, categoryLabel, chartClassName, ...frame }: ChartBarProps) { const msg = useMessages() const locale = useLocale() const nf = React.useMemo(() => new Intl.NumberFormat(locale), [locale]) const flat = layout === "bars" const expanded = stack === "expanded" const byCategory = colorBy === "category" && series.length === 1 const measure = series[0] const rows = React.useMemo<ChartRow[]>(() => { if (sort === "none" || !measure) return data const sign = sort === "ascending" ? 1 : -1 return [...data].sort((a, b) => sign * (((a[measure.key] as number) ?? 0) - ((b[measure.key] as number) ?? 0))) }, [data, sort, measure]) // With colorBy="category" the legend and the colors belong to the categories, not to the one series. const items: ChartSeries[] = byCategory ? rows.map((r) => ({ key: String(r[xKey]), label: categoryFormatter ? categoryFormatter(r[xKey]) : String(r[xKey]), color: typeof r.color === "string" ? r.color : undefined })) : series const number = (value: unknown) => (typeof value === "number" ? value : 0) const table = byCategory && measure ? { columns: [ { key: xKey, label: categoryLabel ?? msg("chart-bar.category", "Category") }, { key: measure.key, label: measure.label, numeric: true, format: (v: unknown) => (valueFormatter ? valueFormatter(number(v), measure.key) : nf.format(number(v))) }, ], rows, } : undefined return ( <ChartFrame data={rows} xKey={xKey} series={items} table={table} summary={summary ?? (byCategory && measure ? describeChart({ data: rows, xKey, series: [measure], mode: "ranking", format: (v, k) => (valueFormatter ? valueFormatter(v, k) : nf.format(v)), formatCategory: (v) => (categoryFormatter ? categoryFormatter(v) : String(v)), msg }) : undefined)} summaryMode={sort === "none" && !byCategory ? "trend" : "ranking"} skeleton="bars" valueFormatter={valueFormatter} categoryFormatter={categoryFormatter} categoryLabel={categoryLabel} chartClassName={cn(chartClassName)} {...frame} > {(chart) => { const compact = new Intl.NumberFormat(chart.locale, { notation: "compact", maximumFractionDigits: 1 }) const tick = (v: number) => (expanded ? `${Math.round(v * 100)}%` : axisFormatter ? axisFormatter(v) : compact.format(v)) const visibleSeries = byCategory ? series : series.filter((s) => !chart.isHidden(s.key)) const lastVisible = visibleSeries.at(-1)?.key const scale = expanded || domain || !valueAxis ? undefined : niceScale({ data: rows, keys: visibleSeries.map((s) => s.key), stacked: stack === "stacked" }) const shown = byCategory ? rows.filter((r) => !chart.isHidden(String(r[xKey]))) : rows const barRadius = (key: string): [number, number, number, number] => { const r = radius if (stack !== "none" && key !== lastVisible) return [0, 0, 0, 0] return flat ? [0, r, r, 0] : [r, r, 0, 0] } return ( <BarChart data={shown} layout={flat ? "vertical" : "horizontal"} stackOffset={expanded ? "expand" : "none"} barCategoryGap={`${GAP[gap] * 100}%`} margin={chart.margin({ top: labels && !flat ? 20 : 8, right: flat && labels ? 44 : 8, left: 0, bottom: 0 })} accessibilityLayer > <defs>{chart.defs}</defs> <CartesianGrid horizontal={flat ? grid === "both" : grid !== "none"} vertical={flat ? grid !== "none" : grid === "both"} /> {flat ? ( <> <YAxis dataKey={xKey} type="category" tickLine={false} axisLine={false} tickMargin={8} width="auto" orientation={chart.rtl ? "right" : "left"} tickFormatter={(v: unknown) => chart.formatCategory(v)} /> <XAxis type="number" hide={!valueAxis} reversed={chart.rtl} tickLine={false} axisLine={false} tickMargin={8} domain={expanded ? [0, 1] : (domain ?? scale?.domain)} ticks={expanded ? undefined : (scale?.ticks ?? domainTicks(domain))} tickFormatter={tick} /> </> ) : ( <> <XAxis dataKey={xKey} type="category" tickLine={false} axisLine={false} tickMargin={8} reversed={chart.rtl} tickFormatter={(v: unknown) => chart.formatCategory(v)} /> <YAxis hide={!valueAxis} type="number" width="auto" tickLine={false} axisLine={false} tickMargin={8} orientation={chart.rtl ? "right" : "left"} domain={expanded ? [0, 1] : (domain ?? scale?.domain)} ticks={expanded ? undefined : (scale?.ticks ?? domainTicks(domain))} tickFormatter={tick} /> </> )} {referenceLines?.map((r) => (flat ? <ReferenceLine key={r.label} x={r.value} stroke="var(--muted-foreground)" strokeDasharray="4 4" label={{ value: r.label, position: "top", fill: "var(--muted-foreground)", fontSize: 11 }} /> : <ReferenceLine key={r.label} y={r.value} stroke="var(--muted-foreground)" strokeDasharray="4 4" label={{ value: r.label, position: chart.rtl ? "insideTopLeft" : "insideTopRight", fill: "var(--muted-foreground)", fontSize: 11 }} />))} {tooltip !== "none" ? <ChartTooltip cursor={{ fill: "var(--muted)", opacity: 0.5 }} content={<ChartTooltipContent indicator={tooltip} hideLabel={flat && false} nameKey={byCategory ? undefined : undefined} />} /> : null} {(byCategory ? [measure!] : series).map((s) => ( <Bar key={s.key} dataKey={s.key} hide={!byCategory && chart.isHidden(s.key)} stackId={stack === "none" ? undefined : "stack"} fill={byCategory ? chart.color(s.key) : chart.fill(s.key)} stroke={chart.patterns ? chart.color(s.key) : undefined} strokeWidth={chart.patterns ? 1.5 : 0} radius={barRadius(s.key)} isAnimationActive={chart.animate} animationDuration={600} maxBarSize={72} > {byCategory || highlight || negativeColor ? shown.map((row) => { const key = String(row[xKey]) const value = number(row[s.key]) const base = byCategory ? chart.fill(key) : negativeColor && value < 0 ? negativeColor : chart.fill(s.key) return <Cell key={key} fill={base} stroke={chart.patterns && byCategory ? chart.color(key) : undefined} strokeWidth={chart.patterns ? 1.5 : 0} fillOpacity={highlight && key !== highlight ? 0.35 : 1} /> }) : null} {labels && stack === "none" ? ( <LabelList dataKey={s.key} position={flat ? "right" : "top"} offset={6} className="fill-foreground text-xs" formatter={(v: unknown) => (typeof v === "number" ? chart.format(v, s.key) : "")} /> ) : null} </Bar> ))} </BarChart> ) }} </ChartFrame> ) } export { ChartBar, type ChartBarProps }Update the import paths to match your project setup.
Usage
import { ChartBar } from "@/components/ballmac/chart-bar"The full example is in the Code tab above.
Examples
Flat bars with labels
Top pages
Views in the last 30 days
"use client"
import { ChartBar } from "@/components/ballmac/chart-bar"
const data = [
{ page: "/pricing", views: 18420 },
{ page: "/docs/get-started/install", views: 15310 },
{ page: "/blocks", views: 12980 },
{ page: "/components/button", views: 9640 },
{ page: "/themes", views: 7220 },
{ page: "/changelog", views: 4150 },
]
export default function ChartBarHorizontal() {
return (
<ChartBar
className="w-full max-w-2xl"
title="Top pages"
description="Views in the last 30 days"
data={data}
xKey="page"
categoryLabel="Page"
layout="bars"
labels
valueAxis={false}
grid="none"
sort="descending"
series={[{ key: "views", label: "Views" }]}
aspect="wide"
/>
)
}Stacked
Qualified leads
By source, stacked
"use client"
import { ChartBar } from "@/components/ballmac/chart-bar"
const data = [
{ quarter: "Q1", inbound: 420, outbound: 310, partner: 140 },
{ quarter: "Q2", inbound: 480, outbound: 360, partner: 190 },
{ quarter: "Q3", inbound: 560, outbound: 340, partner: 260 },
{ quarter: "Q4", inbound: 640, outbound: 410, partner: 330 },
]
export default function ChartBarStacked() {
return (
<ChartBar
className="w-full max-w-2xl"
title="Qualified leads"
description="By source, stacked"
data={data}
xKey="quarter"
categoryLabel="Quarter"
stack="stacked"
series={[
{ key: "inbound", label: "Inbound" },
{ key: "outbound", label: "Outbound" },
{ key: "partner", label: "Partner" },
]}
/>
)
}Expanded to 100%
Plan mix by region
Share of accounts on each plan
"use client"
import { ChartBar } from "@/components/ballmac/chart-bar"
const data = [
{ region: "Americas", free: 5200, pro: 3100, team: 1200 },
{ region: "Europe", free: 4100, pro: 3600, team: 1700 },
{ region: "Asia", free: 6300, pro: 2200, team: 800 },
{ region: "Africa", free: 1900, pro: 700, team: 210 },
]
export default function ChartBarExpanded() {
return (
<ChartBar
className="w-full max-w-2xl"
title="Plan mix by region"
description="Share of accounts on each plan"
data={data}
xKey="region"
categoryLabel="Region"
layout="bars"
stack="expanded"
aspect="wide"
series={[
{ key: "free", label: "Free" },
{ key: "pro", label: "Pro" },
{ key: "team", label: "Team" },
]}
/>
)
}Value labels and a highlight
Support tickets
Opened this week
"use client"
import { ChartBar } from "@/components/ballmac/chart-bar"
const data = [
{ day: "Mon", tickets: 42 },
{ day: "Tue", tickets: 58 },
{ day: "Wed", tickets: 51 },
{ day: "Thu", tickets: 73 },
{ day: "Fri", tickets: 64 },
{ day: "Sat", tickets: 22 },
{ day: "Sun", tickets: 17 },
]
export default function ChartBarLabels() {
return (
<ChartBar
className="w-full max-w-xl"
title="Support tickets"
description="Opened this week"
data={data}
xKey="day"
categoryLabel="Day"
series={[{ key: "tickets", label: "Tickets" }]}
labels
valueAxis={false}
grid="none"
highlight="Thu"
footer="Thursday was the busiest day."
/>
)
}One color per category
Visitors by browser
Each bar has its own color; the legend lists the browsers
"use client"
import { ChartBar } from "@/components/ballmac/chart-bar"
const data = [
{ browser: "Chrome", visitors: 8420 },
{ browser: "Safari", visitors: 5130 },
{ browser: "Firefox", visitors: 1980 },
{ browser: "Edge", visitors: 1610 },
{ browser: "Other", visitors: 640 },
]
export default function ChartBarCategories() {
return (
<ChartBar
className="w-full max-w-2xl"
title="Visitors by browser"
description="Each bar has its own color; the legend lists the browsers"
data={data}
xKey="browser"
categoryLabel="Browser"
colorBy="category"
sort="descending"
series={[{ key: "visitors", label: "Visitors" }]}
/>
)
}Positive and negative
Net cash flow
Money in minus money out
"use client"
import { ChartBar } from "@/components/ballmac/chart-bar"
const data = [
{ month: "May", net: 4200 },
{ month: "Jun", net: -1800 },
{ month: "Jul", net: 3100 },
{ month: "Aug", net: 6400 },
{ month: "Sep", net: -900 },
{ month: "Oct", net: 5200 },
]
export default function ChartBarNegative() {
return (
<ChartBar
className="w-full max-w-2xl"
title="Net cash flow"
description="Money in minus money out"
data={data}
xKey="month"
categoryLabel="Month"
series={[{ key: "net", label: "Net", color: "var(--chart-2)" }]}
negativeColor="var(--destructive)"
axisFormatter={(v) => `${v < 0 ? "−" : ""}$${Math.abs(v) / 1000}k`}
valueFormatter={(v) => `${v < 0 ? "−" : ""}$${Math.abs(v).toLocaleString("en-US")}`}
referenceLines={[{ value: 0, label: "" }]}
footer="Two months were negative."
/>
)
}Ranking
Closed deals
This quarter, by rep
"use client"
import { ChartBar } from "@/components/ballmac/chart-bar"
const data = [
{ rep: "Maya Jensen", closed: 412000 },
{ rep: "Tomás Rivera", closed: 368000 },
{ rep: "Aiko Tanaka", closed: 331000 },
{ rep: "Priya Nair", closed: 296000 },
{ rep: "Liam O'Connor", closed: 244000 },
{ rep: "Sofia Alvarez", closed: 198000 },
]
export default function ChartBarRanked() {
return (
<ChartBar
className="w-full max-w-2xl"
title="Closed deals"
description="This quarter, by rep"
data={data}
xKey="rep"
categoryLabel="Rep"
layout="bars"
sort="descending"
labels
valueAxis={false}
grid="none"
highlight="Maya Jensen"
aspect="wide"
series={[{ key: "closed", label: "Closed" }]}
valueFormatter={(v) => `$${Math.round(v / 1000)}k`}
radius={8}
/>
)
}Patterns for color blindness
Contracts by city
Switch Patterns on: each series gets its own texture as well as its color
"use client"
import { ChartBar } from "@/components/ballmac/chart-bar"
const data = [
{ city: "Lisbon", signed: 128, pending: 64, lost: 22 },
{ city: "Berlin", signed: 172, pending: 81, lost: 31 },
{ city: "Toronto", signed: 144, pending: 58, lost: 27 },
{ city: "Osaka", signed: 96, pending: 49, lost: 18 },
]
export default function ChartBarPatterns() {
return (
<ChartBar
className="w-full max-w-2xl"
title="Contracts by city"
description="Switch Patterns on: each series gets its own texture as well as its color"
data={data}
xKey="city"
categoryLabel="City"
patterns="toggle"
series={[
{ key: "signed", label: "Signed" },
{ key: "pending", label: "Pending" },
{ key: "lost", label: "Lost" },
]}
/>
)
}API reference
| Prop | Type | Default |
|---|---|---|
layout"columns" stand upright with categories along the bottom. "bars" lie flat with categories down the side, which reads better for long names. | "columns" | "bars" | "columns" |
stack"stacked" piles the series up; "expanded" shows each one's share of the total. | "none" | "stacked" | "expanded" | "none" |
radiusCorner radius of the bars, in pixels. | number | 6 |
labelsWrite each bar's value on it. For one series or grouped bars. | boolean | false |
colorByWith one series, "category" colors every bar differently and the legend lists the categories. | "series" | "category" | "series" |
highlightA category to emphasize: the others fade. | string | — |
negativeColorWith one series, color bars below zero differently. | string | — |
sortSort the categories by the first series. | "none" | "ascending" | "descending" | "none" |
gapSpace between bars. | "tight" | "default" | "wide" | "default" |
grid | "value" | "both" | "none" | "value" |
valueAxisShow the value axis. Hide it when `labels` already write the numbers. | boolean | true |
axisFormatterWrites a tick on the value axis. Defaults to a compact number. | (value: number) => string | — |
domain | [number | "auto", number | "auto"] | — |
referenceLines | { value: number; label: string }[] | — |
tooltip | "dot" | "line" | "dashed" | "none" | "dot" |
value* | number | — |
label* | string | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Arrow keys | Move between bars (accessibilityLayer) |
| Tab, Enter / Space | Legend buttons switch series or categories; the Chart / Table switch shows the numbers as a table |
Quality scorecard
Every applicable check passes
Measured in Chromium 153.0.8010.12 on October 19, 2026, on version 1.0.0, across 9 examples.
Passed: Accessibility scan
No serious or critical issues in light and dark, on a phone and a desktop (axe-core 4.13.0, WCAG 2.1 AA and best-practice rules).
Passed: Small screens
No sideways scrolling at 360, 390 or 1280 px wide.
Passed: Reduced motion
No endless animation to begin with.
Not applicable: Right to left
Not mirrored by design: Recharts draws its axes in SVG coordinates, which do not mirror.
Passed: Clean console
No console errors or React hydration errors while loading.
Passed: Keyboard
35 tab stops, each showing a visible change when it takes focus. 2 documented keys.
Not applicable: Loading, empty and error
Does not show data or take submissions.
Passed: Automated tests
Covered by charts-batch-families.test.tsx.
For information: Footprint
3.2 KB of source (gzip) in 1 file. Installs recharts.
Automated checks find a subset of problems and do not replace testing with a screen reader and a keyboard in your own app. What they miss.
Use with AI
<ChartBar title description data xKey series={[{ key, label, color? }]} />. layout: columns | bars (flat, for long names). stack: none | stacked | expanded. Also labels, colorBy ('category' for one color per bar), highlight, negativeColor, sort, gap, valueAxis, axisFormatter, referenceLines, legend, patterns, state and every ChartFrame prop. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Chart Bar (@ballmac/chart-bar) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Comparing values across categories
- Rankings (layout='bars', sort='descending')
- Parts of a whole across groups (stack)
Not for
- A trend over time with many points (use chart-area or chart-line)
- A single share of a whole (use chart-pie or chart-radial)
Registry JSON: https://ui.ballmac.com/r/chart-bar.json
Credits
Free to use in personal and commercial projects.
- npm
- recharts@^3
Pairs well with
Chart Frame
The card around a Recharts chart: a Table view with CSV download, a legend that toggles series, patterns so series differ by more than color, a summary for screen readers, and loading, empty and error states.
Stat Card
A metric card with a clear comparison trend, optional visual, and readable value hierarchy.
Segmented Control
A macOS segmented control: mutually exclusive options with a selected pill that springs between segments and hairline dividers between unselected ones. A Radix radio group underneath.
Activity Feed
A compact actor and action feed with timestamps and an empty state.
Ballmac UI Pro
Need the whole screen, not one piece?
150 premium blocks (dashboards, settings, calendars, mail, shops, onboarding), a private shadcn registry and Figma tokens. Blocks that show data come with loading, empty and error states built in.
Not ready? Try three Pro blocks free
Leave your email and the blocks appear right here, ready to copy. Yours to use in any project.