Installation
npx shadcn@latest add https://ui.srb.codes/r/pie-chart.json
Usage
The chart only ships in one shape — padded sectors with rounded corners, all colored by the same blue-500→blue-400 gradient. Per-sector colors are intentionally absent so the eye reads the chart as one piece, with selection providing the hierarchy.
"use client";
import { PieChart } from "@/components/ui/pie-chart";
const data = [
{ ticker: "NVDA", weight: 30 },
{ ticker: "AAPL", weight: 22 },
{ ticker: "MSFT", weight: 20 },
{ ticker: "GOOG", weight: 18 },
{ ticker: "AMZN", weight: 5 },
{ ticker: "META", weight: 5 }
];
export function Portfolio() {
return (
<PieChart
data={data}
dataKey="weight"
nameKey="ticker"
renderCenter={({ active, data }) => {
if (!active) {
return (
<div className="text-center">
<div className="text-foreground text-sm font-medium">
Portfolio
</div>
<div className="text-muted-foreground text-xs">
{data.length} positions
</div>
</div>
);
}
return (
<div className="text-center">
<div className="text-foreground text-2xl font-semibold">
{active.value}%
</div>
<div className="text-muted-foreground text-xs">{active.name}</div>
</div>
);
}}
/>
);
}Interaction states
The chart has three states. They cascade — click sets a sticky selection, hover overrides it visually for as long as the cursor stays on a sector, and leaving brings the click-selection back.
Default. Nothing is hovered or selected. Every sector renders with the gradient and renderCenter is called with active: null. Use this for the aggregate or summary view (total value, position count, "All time").
Hover. The hovered sector keeps the gradient; every other sector renders in a muted grey gradient. The triangle pointer rotates to the hovered sector's midAngle and renderCenter is called with the active sector.
Click. Same visual as hover, but the selection sticks after mouseleave. Clicking the same sector again clears it; clicking a different sector switches selection.
Reset. Clicking anywhere outside the chart container — or in the empty space inside it (the donut hole, the gaps between sectors) — clears the click selection and returns the chart to its default state.
Patterns
Linear variant
The chart ships with two gradient styles. The default radial variant sweeps the gradient outward from the chart center as a concentric ring, giving each sector a soft inner-to-outer falloff. Switch variant="linear" for a flat top-to-bottom gradient across each sector — quieter, more typographic, and easier to read against busy backgrounds.
<PieChart data={data} dataKey="weight" nameKey="ticker" variant="linear" />To recolor either variant, edit the PIE_CHART_COLORS map at the top of pie-chart.tsx. Each entry takes plain CSS colors or { light, dark } pairs that swap automatically inside a .dark ancestor.
Custom center content
renderCenter is the only way the chart talks back to your UI — give it whatever React node makes sense for your data. The callback receives { active, data }. active is null in the default state (use this for an aggregate) and an object with { item, index, name, value } when a sector is hovered or selected.
<PieChart
data={traffic}
dataKey="visitors"
nameKey="source"
renderCenter={({ active, data }) => {
if (!active) {
const total = data.reduce((s, d) => s + d.visitors, 0);
return (
<div className="text-center">
<div className="text-2xl font-semibold tabular-nums">
{total.toLocaleString()}
</div>
<div className="text-xs text-muted-foreground">Visitors</div>
</div>
);
}
return (
<div className="text-center">
<div className="text-2xl font-semibold tabular-nums">
{active.value.toLocaleString()}
</div>
<div className="text-xs text-muted-foreground">{active.name}</div>
</div>
);
}}
/>Custom label format
By default each sector shows its share as a rounded percentage (30%, 5%). Override formatLabel to show the raw value, a name, or anything else — return null to hide a particular sector's label.
<PieChart
data={data}
dataKey="weight"
nameKey="ticker"
formatLabel={({ name, percent }) =>
percent < 0.05 ? null : `${name} ${Math.round(percent * 100)}%`
}
/>Hiding labels
Pass showLabels={false} for a clean ring without external labels — useful when the legend lives elsewhere on the page.
<PieChart data={data} dataKey="weight" nameKey="ticker" showLabels={false} />Solid pie (no donut hole)
Set innerRadius={0} to drop the donut hole. The pointer and tick ring fade out with it — they live inside the hole — but the gradient, labels, and selection states still work.
<PieChart data={data} dataKey="weight" nameKey="ticker" innerRadius={0} />Tighter sectors
Lower paddingAngle and cornerRadius for a more continuous ring. paddingAngle={0} removes the gap entirely; rounded corners alone still keep each sector visually distinct.
<PieChart
data={data}
dataKey="weight"
nameKey="ticker"
paddingAngle={1}
cornerRadius={4}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
data | TData[] | — | Rows powering each sector. |
dataKey | keyof TData | — | Numeric field on each row that drives sector size. |
nameKey | keyof TData | — | Field on each row used as the sector's display name. |
innerRadius | number | 60 | Donut hole radius as a percentage of the chart's half-extent (0–100). Set 0 for a solid pie. |
outerRadius | number | 70 | Outer ring radius as a percentage of the chart's half-extent. Leaves room outside for sector labels. |
paddingAngle | number | 4 | Gap between sectors, in degrees. |
cornerRadius | number | 6 | Sector corner radius, in px. |
showLabels | boolean | true | Show a label outside each sector. |
formatLabel | (ctx: LabelContext<TData>) => string | null | — | Format the per-sector label. Defaults to ${Math.round(percent * 100)}%. Return null to skip a label. |
renderCenter | (ctx: CenterContext<TData>) => React.ReactNode | — | Custom donut-hole content. Receives { active, data }; active is null in the default state. |
className | string | — | Forwarded to the chart wrapper. |
Types
type ActiveSector<TData> = {
item: TData;
index: number;
name: string;
value: number;
};
type CenterContext<TData> = {
active: ActiveSector<TData> | null;
data: TData[];
};
type LabelContext<TData> = {
value: number;
percent: number; // 0–1
name: string;
item: TData;
index: number;
};Why one gradient, no per-sector colors?
Per-key palettes work when each category needs a distinct identity (browsers, departments, regions). For ranking-by-size charts — portfolio weights, traffic share, vote percentages — they get in the way: the eye spends effort decoding which color means what before reading the value. A single gradient with selection-driven contrast lets the chart read instantly: the active slice is the answer, the rest is context.
If you do need per-sector colors, drop down to Recharts directly — <Pie data={...} dataKey="…"> with <Cell fill="…" /> children gives you full control while keeping the rest of this component's structure as a starting point.
Credits
Built on top of Recharts, inspired by Evil Charts.