Something new is coming.Join the waitlist

Pie Chart

PreviousNext

Padded-sector pie chart with hover and click selection, a tick ring, a rotating triangle pointer, and a customizable center detail. Built on Recharts, inspired by Evil Charts.

$101.5k
Total

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

PropTypeDefaultDescription
dataTData[]Rows powering each sector.
dataKeykeyof TDataNumeric field on each row that drives sector size.
nameKeykeyof TDataField on each row used as the sector's display name.
innerRadiusnumber60Donut hole radius as a percentage of the chart's half-extent (0–100). Set 0 for a solid pie.
outerRadiusnumber70Outer ring radius as a percentage of the chart's half-extent. Leaves room outside for sector labels.
paddingAnglenumber4Gap between sectors, in degrees.
cornerRadiusnumber6Sector corner radius, in px.
showLabelsbooleantrueShow a label outside each sector.
formatLabel(ctx: LabelContext<TData>) => string | nullFormat the per-sector label. Defaults to ${Math.round(percent * 100)}%. Return null to skip a label.
renderCenter(ctx: CenterContext<TData>) => React.ReactNodeCustom donut-hole content. Receives { active, data }; active is null in the default state.
classNamestringForwarded 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.