A pie or donut with the total in the hole, outside labels, a pulled-out slice and a half ring, in a frame with a legend that switches slices off, a table of value and share, a written ranking, patterns and states.
Visitors by source
Last 30 days
"use client"
import { ChartPie } from "@/components/ballmac/chart-pie"
const data = [
{ name: "search", label: "Search", value: 4210 },
{ name: "direct", label: "Direct", value: 2890 },
{ name: "social", label: "Social", value: 1630 },
{ name: "email", label: "Email", value: 980 },
]
export default function ChartPieDemo() {
return <ChartPie className="w-full max-w-md" title="Visitors by source" description="Last 30 days" data={data} valueLabel="visitors" categoryLabel="Source" />
}Installation
$ pnpm dlx shadcn@latest add @ballmac/chart-pieInstall 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-pie.tsx// Ballmac UI: Chart Pie. https://ui.ballmac.com/components/chart-pie "use client" import * as React from "react" import { Cell, Pie, PieChart, Sector } from "recharts" import { ChartCenterLabel, ChartTooltip, ChartTooltipContent } from "@/components/ballmac/chart" import { ChartFrame, type ChartFrameProps, type ChartRow, type ChartSeries } from "@/components/ballmac/chart-frame" import { useLocale, useMessages } from "@/lib/ballmac/i18n" type ChartPieProps = Omit<ChartFrameProps, "children" | "series" | "xKey" | "summaryMode" | "skeleton" | "table" | "data"> & { /** One row per slice: its name under `nameKey`, its number under `valueKey`, and optionally a `label` and a `color`. */ data: ChartRow[] /** The key of each slice's name. */ nameKey?: string /** The key of each slice's number. */ valueKey?: string /** What the numbers count, for the center and the table: "visitors". */ valueLabel?: string /** A ring with a hole (default) or a full pie. */ donut?: boolean /** Draw only the upper half, like a gauge. */ half?: boolean /** Write the total in the hole of a donut. Pass your own `{ value, label }`, or false to leave it empty. */ centerLabel?: boolean | { value: string; label: string } /** "outside" writes each slice's name and share beside it. */ labels?: "none" | "outside" /** A slice to pull out, by name. */ highlight?: string /** Leave a small gap between slices. */ gap?: boolean tooltip?: boolean } /** * A pie or donut in a ChartFrame: the total in the hole, outside labels, a pulled-out slice, a half ring, a legend that switches slices off (the total and * the shares follow), a table of value and share, a written ranking, patterns, and loading, empty and error states. */ function ChartPie({ data, nameKey = "name", valueKey = "value", valueLabel, donut = true, half = false, centerLabel = true, labels = "none", highlight, gap = true, tooltip = true, categoryLabel, categoryFormatter, valueFormatter, summary, ...frame }: ChartPieProps) { const msg = useMessages() const locale = useLocale() const nf = React.useMemo(() => new Intl.NumberFormat(locale), [locale]) const pct = React.useMemo(() => new Intl.NumberFormat(locale, { style: "percent", maximumFractionDigits: 0 }), [locale]) const number = (v: unknown) => (typeof v === "number" && Number.isFinite(v) ? v : 0) const write = (v: number) => (valueFormatter ? valueFormatter(v, valueKey) : nf.format(v)) const items: ChartSeries[] = data.map((row) => { const name = String(row[nameKey]) return { key: name, label: typeof row.label === "string" ? row.label : categoryFormatter ? categoryFormatter(row[nameKey]) : name, color: typeof row.color === "string" ? row.color : undefined } }) const total = data.reduce((sum, row) => sum + number(row[valueKey]), 0) const share = (v: number) => (total > 0 ? pct.format(v / total) : "–") const ranked = [...data].sort((a, b) => number(b[valueKey]) - number(a[valueKey])) const labelOf = (name: string) => items.find((i) => i.key === name)?.label ?? name const totalText = valueLabel ? `${write(total)} ${valueLabel}` : write(total) const written = summary ?? (data.length ? [ msg("chart-pie.total", { one: "{total} in one group.", other: "{total} in total across {count} groups." }, { total: totalText, count: data.length }), ranked.map((row) => `${labelOf(String(row[nameKey]))} ${share(number(row[valueKey]))} (${write(number(row[valueKey]))})`).join(", ") + ".", ].join(" ") : "") return ( <ChartFrame data={data} xKey={nameKey} series={items} summary={written} aspect={half ? "wide" : "square"} skeleton="donut" legend="toggle" categoryLabel={categoryLabel} valueFormatter={valueFormatter} categoryFormatter={categoryFormatter} table={{ columns: [ { key: nameKey, label: categoryLabel ?? msg("chart-pie.category", "Category"), format: (v) => labelOf(String(v)) }, { key: valueKey, label: valueLabel ? valueLabel.charAt(0).toUpperCase() + valueLabel.slice(1) : msg("chart-pie.value", "Value"), numeric: true, format: (v) => write(number(v)) }, { key: "__share", label: msg("chart-pie.share", "Share"), numeric: true, format: (_v, row) => share(number(row[valueKey])) }, ], rows: data, }} {...frame} > {(chart) => { const shown = data.filter((row) => !chart.isHidden(String(row[nameKey]))) const shownTotal = shown.reduce((sum, row) => sum + number(row[valueKey]), 0) // Slices run the way the page reads: clockwise left to right, counterclockwise right to left. const [startAngle, endAngle] = half ? (chart.rtl ? [0, 180] : [180, 0]) : chart.rtl ? [90, 450] : [90, -270] const center = centerLabel === true ? { value: write(shownTotal), label: valueLabel ?? msg("chart-pie.totalLabel", "Total") } : centerLabel || null return ( <PieChart accessibilityLayer margin={{ top: labels === "outside" ? 24 : 8, right: labels === "outside" ? 76 : 8, bottom: half ? 0 : 8, left: labels === "outside" ? 76 : 8 }}> <defs>{chart.defs}</defs> {tooltip ? <ChartTooltip content={<ChartTooltipContent hideLabel nameKey={nameKey} />} /> : null} <Pie data={shown} dataKey={valueKey} nameKey={nameKey} innerRadius={donut ? (half ? "100%" : "58%") : 0} outerRadius={half ? "165%" : labels === "outside" ? "70%" : "92%"} startAngle={startAngle} endAngle={endAngle} cy={half ? "88%" : "50%"} paddingAngle={gap ? 2 : 0} cornerRadius={donut ? 4 : 2} stroke="var(--card)" strokeWidth={gap ? 0 : 2} isAnimationActive={chart.animate} animationDuration={700} label={ labels === "outside" ? (p: { name?: string; percent?: number; x?: number; y?: number; textAnchor?: string; payload?: ChartRow }) => ( <text x={p.x} y={p.y} textAnchor={p.textAnchor as "start" | "middle" | "end"} dominantBaseline="central" className="fill-foreground text-xs"> {labelOf(String(p.name ?? ""))} <tspan className="fill-muted-foreground">{pct.format(p.percent ?? 0)}</tspan> </text> ) : false } labelLine={labels === "outside" ? { stroke: "var(--border)" } : false} {...(highlight !== undefined ? { shape: (p: React.ComponentProps<typeof Sector> & { payload?: ChartRow }) => ( <Sector {...p} outerRadius={(p.outerRadius ?? 0) + (String(p.payload?.[nameKey]) === highlight ? 8 : 0)} /> ), } : {})} > {shown.map((row) => { const key = String(row[nameKey]) return <Cell key={key} fill={chart.fill(key)} stroke={chart.patterns ? chart.color(key) : undefined} strokeWidth={chart.patterns ? 1.5 : undefined} /> })} </Pie> {donut && center ? <ChartCenterLabel value={center.value} label={center.label} cy={half ? 0.88 : 0.5} /> : null} </PieChart> ) }} </ChartFrame> ) } export { ChartPie, type ChartPieProps }Update the import paths to match your project setup.
Usage
import { ChartPie } from "@/components/ballmac/chart-pie"The full example is in the Code tab above.
Examples
Full pie with labels
Accounts by plan
Percent of all accounts
"use client"
import { ChartPie } from "@/components/ballmac/chart-pie"
const data = [
{ name: "paid", label: "Paid", value: 52 },
{ name: "trial", label: "Trial", value: 31 },
{ name: "free", label: "Free", value: 17 },
]
export default function ChartPieFull() {
return <ChartPie className="w-full max-w-md" title="Accounts by plan" description="Percent of all accounts" data={data} donut={false} labels="outside" gap={false} valueFormatter={(v) => `${v}%`} categoryLabel="Plan" />
}Half ring
Storage
10 GB plan
"use client"
import { ChartPie } from "@/components/ballmac/chart-pie"
const data = [
{ name: "used", label: "Used", value: 7420, color: "var(--chart-1)" },
{ name: "reserved", label: "Reserved", value: 1580, color: "var(--chart-3)" },
{ name: "free", label: "Free", value: 1000, color: "var(--muted)" },
]
export default function ChartPieHalf() {
return <ChartPie className="w-full max-w-md" title="Storage" description="10 GB plan" data={data} half valueLabel="MB" valueFormatter={(v) => `${v.toLocaleString("en-US")}`} legend="static" categoryLabel="Kind" />
}A pulled-out slice
Monthly budget
Rent is the largest share
"use client"
import { ChartPie } from "@/components/ballmac/chart-pie"
const data = [
{ name: "rent", label: "Rent", value: 1850 },
{ name: "food", label: "Food", value: 640 },
{ name: "transport", label: "Transport", value: 210 },
{ name: "fun", label: "Fun", value: 320 },
{ name: "savings", label: "Savings", value: 480 },
]
export default function ChartPieHighlight() {
return (
<ChartPie
className="w-full max-w-lg"
title="Monthly budget"
description="Rent is the largest share"
data={data}
labels="outside"
highlight="rent"
centerLabel={false}
valueFormatter={(v) => `$${v.toLocaleString("en-US")}`}
categoryLabel="Category"
/>
)
}Patterns for color blindness
Browsers
Switch Patterns on to read the slices without color
"use client"
import { ChartPie } from "@/components/ballmac/chart-pie"
const data = [
{ name: "chrome", label: "Chrome", value: 8420 },
{ name: "safari", label: "Safari", value: 5130 },
{ name: "firefox", label: "Firefox", value: 1980 },
{ name: "edge", label: "Edge", value: 1610 },
{ name: "other", label: "Other", value: 640 },
]
export default function ChartPiePatterns() {
return <ChartPie className="w-full max-w-md" title="Browsers" description="Switch Patterns on to read the slices without color" data={data} patterns="toggle" valueLabel="visitors" categoryLabel="Browser" />
}A goal ring with your own center
Quarterly goal
Revenue target
"use client"
import { ChartPie } from "@/components/ballmac/chart-pie"
const data = [
{ name: "done", label: "Done", value: 72, color: "var(--chart-2)" },
{ name: "left", label: "Left", value: 28, color: "var(--muted)" },
]
export default function ChartPieCenter() {
return (
<ChartPie
className="w-full max-w-sm"
title="Quarterly goal"
description="Revenue target"
data={data}
centerLabel={{ value: "72%", label: "of $400k" }}
legend="static"
tooltip={false}
valueFormatter={(v) => `${v}%`}
categoryLabel="Status"
/>
)
}API reference
| Prop | Type | Default |
|---|---|---|
data*One row per slice: its name under `nameKey`, its number under `valueKey`, and optionally a `label` and a `color`. | ChartRow[] | — |
nameKeyThe key of each slice's name. | string | "name" |
valueKeyThe key of each slice's number. | string | "value" |
valueLabelWhat the numbers count, for the center and the table: "visitors". | string | — |
donutA ring with a hole (default) or a full pie. | boolean | true |
halfDraw only the upper half, like a gauge. | boolean | false |
centerLabelWrite the total in the hole of a donut. Pass your own `{ value, label }`, or false to leave it empty. | boolean | { value: string; label: string } | true |
labels"outside" writes each slice's name and share beside it. | "none" | "outside" | "none" |
highlightA slice to pull out, by name. | string | — |
gapLeave a small gap between slices. | boolean | true |
tooltip | boolean | true |
value* | string | — |
label* | string | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Arrow keys | Move between slices (accessibilityLayer) |
| Tab, Enter / Space | Legend buttons switch slices off and on; the Chart / Table switch shows value and share as a table |
| Screen readers | The summary lists every slice with its share and value, largest first |
Quality scorecard
Every applicable check passes
Measured in Chromium 153.0.8010.12 on October 19, 2026, on version 1.0.0, across 6 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
36 tab stops, each showing a visible change when it takes focus. 3 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
2.7 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
<ChartPie title description data={[{ name, label?, value, color? }]} valueLabel='visitors' />. donut (default) or donut={false}; half for a semicircle; centerLabel (true writes the total; or { value, label }); labels='outside'; highlight (a slice name); gap; legend; patterns; state. The legend switches slices off and the total and shares follow. Slices are rows, not series. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Chart Pie (@ballmac/chart-pie) to this project with the shadcn MCP, then use it where it fits.
Use it for
- A few parts of one whole (up to about six)
- A goal or quota ring (two slices, custom centerLabel)
Not for
- More than six categories or exact comparison (use chart-bar with layout='bars')
- Change over time (use chart-area or chart-line)
Registry JSON: https://ui.ballmac.com/r/chart-pie.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.
Activity Feed
A compact actor and action feed with timestamps and an empty state.
Audio Player
A podcast-style audio player: play and pause, skip back and forward, a scrubber with chapter ticks, a named current chapter and a speed control. Works with a real file or as a silent demo.
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.