# Pie Chart

Show how a total divides into shares as a donut or pie chart.

Storage use with an unfilled share

[Complete TSX file](/examples/pie-chart/default.tsx)

```tsx
"use client";

import { PieChart } from "@/siteplane/components/pie-chart";

const storage = [
  { id: "documents", label: "Documents", value: 200 },
  { id: "images", label: "Images", value: 350 },
  { id: "video", label: "Video", value: 100 },
  { id: "other", label: "Other", value: 120 },
  { empty: true, id: "free", label: "Free", value: 230 },
];

export default function Example() {
  return (
    <PieChart
      data={storage}
      formatValue={(value) => `${value} GB`}
      label="Storage use by file type"
    />
  );
}
```

A Pie Chart shows a few shares of one total, as a donut by default or as a pie that fills the whole disc. The shares shade the brand color from strong to light, which suits the parts of one whole. Mark a share with `empty` when it is the unfilled rest, such as free storage. Hover a share to read its value; the card trails beside the share.

## Installation

Complete [one-time setup](/docs/installation/) first.

```bash
pnpm dlx shadcn@4.18.0 add @siteplane/pie-chart
```

With npm: `npx shadcn@4.18.0 add @siteplane/pie-chart`. [Registry JSON](/registry/pie-chart.json) lists source files and dependencies.

For this page’s examples, also install:

```bash
pnpm dlx shadcn@4.18.0 add @siteplane/toggle-group
```

## Usage

```tsx
import { PieChart } from "@/siteplane/components/pie-chart";

<PieChart
  label="Storage use"
  data={[
    { id: "documents", label: "Documents", value: 200 },
    { id: "images", label: "Images", value: 350 },
  ]}
/>;
```

## Pie

`variant="pie"` fills the whole disc with a wedge per share. The default, `donut`, draws the shares as a ring around a hole that can hold content of its own.

Monthly revenue by plan as a pie

[Complete TSX file](/examples/pie-chart/pie.tsx)

```tsx
"use client";

import { PieChart } from "@/siteplane/components/pie-chart";

const plans = [
  { id: "enterprise", label: "Enterprise", value: 48200 },
  { id: "business", label: "Business", value: 31500 },
  { id: "team", label: "Team", value: 18900 },
  { id: "starter", label: "Starter", value: 7400 },
];

const currency = new Intl.NumberFormat("en-US", {
  currency: "USD",
  maximumFractionDigits: 0,
  style: "currency",
});

export default function Example() {
  return (
    <PieChart
      data={plans}
      formatValue={(value) => currency.format(value)}
      label="Monthly revenue by plan"
      variant="pie"
    />
  );
}
```

## Content in the hole

Pass `centerValue` to show a figure in the hole of the donut, such as the total, and `centerLabel` for its caption. The chart fits the figure into the hole: in full at the largest of 30, 24 or 20 px that fits, and otherwise in compact notation, such as $2.5M, at the largest step from 30 px down. Screen readers read a compact figure in full. `centerFormat` takes Intl.NumberFormat options, such as a currency, and the chart adds the compact notation itself. The caption stays on one line and ends in an ellipsis where the hole is too narrow for it.

Donuts with a total and a compact total in their holes

[Complete TSX file](/examples/pie-chart/center.tsx)

```tsx
"use client";

import { PieChart } from "@/siteplane/components/pie-chart";

const tickets = [
  { id: "open", label: "Open", value: 128 },
  { id: "pending", label: "Pending", value: 54 },
  { id: "on-hold", label: "On hold", value: 23 },
];

const revenue = [
  { id: "enterprise", label: "Enterprise", value: 1_486_000 },
  { id: "team", label: "Team", value: 742_500 },
  { id: "starter", label: "Starter", value: 257_500 },
];

const sum = (shares: readonly { value: number }[]) =>
  shares.reduce((total, share) => total + share.value, 0);

const dollars = {
  currency: "USD",
  maximumFractionDigits: 0,
  minimumFractionDigits: 0,
  style: "currency",
} satisfies Intl.NumberFormatOptions;
const formatDollars = new Intl.NumberFormat("en-US", dollars);

export default function Example() {
  return (
    <div className="flex flex-wrap items-start justify-center gap-x-12 gap-y-10">
      <PieChart
        centerLabel="Tickets"
        centerValue={sum(tickets)}
        data={tickets}
        label="Unresolved tickets by status"
      />
      <PieChart
        centerFormat={dollars}
        centerLabel="Revenue"
        centerValue={sum(revenue)}
        data={revenue}
        formatValue={(value) => formatDollars.format(value)}
        label="Revenue by plan this year"
      />
    </div>
  );
}
```

For content of your own, pass `center` instead. It is laid out in the square around the hole and cut off at the hole’s round edge, so it never covers the ring; keep it short. The content in the hole never takes the pointer, and it is not part of the chart’s accessible name, which is `label` alone. A pie has no hole and shows none of it.

## Without a legend

The legend lists the shares beside the ring, each with its color, and moves below the ring when there is no room beside it. Set `legend={false}` when the text around the chart already says what it shows, as in a compact card. Screen readers still read every share and its value, as the description of the chart.

A compact storage donut without a legend

[Complete TSX file](/examples/pie-chart/without-legend.tsx)

```tsx
"use client";

import { PieChart } from "@/siteplane/components/pie-chart";

const storage = [
  { id: "used", label: "Used", value: 7.2 },
  { empty: true, id: "free", label: "Free", value: 2.8 },
];

export default function Example() {
  return (
    <div className="flex items-center gap-4">
      <PieChart
        data={storage}
        formatValue={(value) => `${value} GB`}
        label="Storage"
        legend={false}
        size={64}
      />
      <div className="grid gap-1">
        <p className="text-sm font-medium">Storage</p>
        <p className="text-sm text-muted-foreground tabular-nums">
          7.2 GB of 10 GB used
        </p>
      </div>
    </div>
  );
}
```

## Categorical palette

With `palette="categorical"`, each share takes its own chart color, which suits unrelated categories. Pass `formatValue` to format the value shown in the tooltip and read out with each legend item.

Traffic by channel in percent

[Complete TSX file](/examples/pie-chart/categorical.tsx)

```tsx
"use client";

import { PieChart } from "@/siteplane/components/pie-chart";

const channels = [
  { id: "organic", label: "Organic search", value: 38 },
  { id: "direct", label: "Direct", value: 24 },
  { id: "referral", label: "Referral", value: 18 },
  { id: "social", label: "Social", value: 12 },
  { id: "email", label: "Email", value: 8 },
];

export default function Example() {
  return (
    <PieChart
      data={channels}
      formatValue={(value) => `${value}%`}
      label="Traffic by channel"
      palette="categorical"
    />
  );
}
```

## Pop-out

With `popOut`, the highlighted share steps 10 px out along its middle angle and glides back in when it is no longer highlighted. A mouse over a legend entry pops its share out too, without opening the tooltip. Without `popOut` no share moves, and the legend does not react to the mouse.

Sales by region with a share that pops out

[Complete TSX file](/examples/pie-chart/pop-out.tsx)

```tsx
"use client";

import { PieChart } from "@/siteplane/components/pie-chart";

const regions = [
  { id: "north-america", label: "North America", value: 412000 },
  { id: "europe", label: "Europe", value: 268000 },
  { id: "asia-pacific", label: "Asia Pacific", value: 174000 },
  { id: "latin-america", label: "Latin America", value: 61000 },
];

const currency = new Intl.NumberFormat("en-US", {
  currency: "USD",
  notation: "compact",
  style: "currency",
});

export default function Example() {
  return (
    <PieChart
      data={regions}
      formatValue={(value) => currency.format(value)}
      label="Sales by region"
      popOut
    />
  );
}
```

## Changing data

When `data` changes, the chart glides to it. Each share moves to its new size and place; a new share grows from nothing, and a share that is gone shrinks away. Shares are told by their `id`, so give every share a stable one. The legend adds and removes its entries with the list motion of [Motion](/docs/components/motion/), and the tooltip stays on the share it was on.

A ring that glides when the period or the shares change

[Complete TSX file](/examples/pie-chart/changing.tsx)

```tsx
"use client";

import { useState } from "react";
import { PieChart } from "@/siteplane/components/pie-chart";
import {
  ToggleGroup,
  ToggleGroupItem,
} from "@/siteplane/components/toggle-group";

const sources = [
  { id: "search", label: "Search" },
  { id: "direct", label: "Direct" },
  { id: "social", label: "Social" },
  { id: "referral", label: "Referral" },
  { id: "email", label: "Email" },
];

const visits = {
  month: [5265, 3120, 1755, 1365, 975],
  quarter: [16772, 8985, 5391, 4193, 2995],
};

type Period = keyof typeof visits;

export default function Example() {
  const [period, setPeriod] = useState<Period>("month");
  const [shown, setShown] = useState(() => sources.map((source) => source.id));
  const data = sources.flatMap((source, index) =>
    shown.includes(source.id)
      ? [{ ...source, value: visits[period][index] ?? 0 }]
      : [],
  );
  const total = data.reduce((sum, item) => sum + item.value, 0);

  return (
    <div className="grid w-full max-w-md gap-4">
      <ToggleGroup
        aria-label="Period"
        onValueChange={(next) => {
          if (next[0]) setPeriod(next[0] as Period);
        }}
        size="sm"
        value={[period]}
        variant="outline"
      >
        <ToggleGroupItem value="month">Last month</ToggleGroupItem>
        <ToggleGroupItem value="quarter">Last quarter</ToggleGroupItem>
      </ToggleGroup>
      <ToggleGroup
        aria-label="Sources"
        multiple
        onValueChange={(next) => setShown(next)}
        size="sm"
        value={shown}
        variant="outline"
      >
        {sources.map((source) => (
          <ToggleGroupItem key={source.id} value={source.id}>
            {source.label}
          </ToggleGroupItem>
        ))}
      </ToggleGroup>
      <PieChart
        className="items-center"
        data={data}
        formatValue={(count) =>
          `${count.toLocaleString("en-US")} visitors · ${Math.round((count / total) * 100)}%`
        }
        label="Visitors by source"
        size={176}
      />
    </div>
  );
}
```

## Sizes

`size` sets the diameter of the chart in pixels. The chart shrinks to fit a narrow container, and the legend moves below the ring when there is no room beside it.

Rings of 120, 200 and 280 pixels

[Complete TSX file](/examples/pie-chart/sizes.tsx)

```tsx
"use client";

import { PieChart } from "@/siteplane/components/pie-chart";

const storage = [
  { id: "documents", label: "Documents", value: 200 },
  { id: "images", label: "Images", value: 350 },
  { id: "video", label: "Video", value: 100 },
  { id: "other", label: "Other", value: 120 },
  { empty: true, id: "free", label: "Free", value: 230 },
];

const sizes = [120, 200, 280] as const;

export default function Example() {
  return (
    <div className="flex flex-wrap items-start justify-center gap-x-12 gap-y-10">
      {sizes.map((size) => (
        <div className="grid justify-items-center gap-4" key={size}>
          <p className="text-xs text-muted-foreground tabular-nums">
            {size} px
          </p>
          <PieChart
            data={storage}
            label={`Storage use, ${size} pixels`}
            size={size}
          />
        </div>
      ))}
    </div>
  );
}
```

## API reference

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| PieChart.data | readonly PieChartDatum[] | required | Shares of the total, drawn clockwise from the top in the order given. |
| PieChart.label | string | required | Accessible name of the chart. |
| PieChart.aria-describedby | string | unset | Id of an element that describes the chart, read after the description that the chart gives itself. |
| PieChart.variant | donut \| pie | donut | donut draws the shares as a ring around a hole; pie fills the whole disc with a wedge per share. |
| PieChart.centerValue | number | unset | A figure in the hole of the donut, such as the total. The chart fits it: in full at 30, 24 or 20 px, otherwise compact at the largest step that fits. Screen readers read it in full. |
| PieChart.centerLabel | string | unset | A caption under centerValue: one line of 12 px muted text that ends in an ellipsis where the hole is too narrow. |
| PieChart.centerFormat | Intl.NumberFormatOptions | unset | How centerValue is written in locale, such as a currency. The chart adds the compact notation when the figure needs it. |
| PieChart.center | ReactNode | unset | Content of your own in the hole, in place of centerValue and centerLabel, cut off at the hole’s edge so it never covers the ring. It never takes the pointer and is not part of the accessible name. A pie has no hole and shows none. |
| PieChart.legend | boolean | true | Lists the shares beside the ring, each with its color, and reads each value to screen readers. Without it the shares and their values are the description of the chart. |
| PieChart.palette | brand \| categorical | brand | brand shades the brand color from strong to light; categorical gives each share its own chart color. A share’s own color wins either way. |
| PieChart.popOut | boolean | false | Steps the highlighted share 10 px out along its middle angle, at any size, and lets a mouse over a legend entry pop its share out too. A share that steps out reaches up to 10 px beyond the box of the chart. |
| PieChart.size | number | 200 | Diameter of the chart in pixels. |
| PieChart.formatValue | (value: number) => string | locale number (1,234.5) | Formats the value in the tooltip and in the text that screen readers read for each share. |
| PieChart.locale | string | en-US | Locale of the numbers. |
| PieChart.emptyLabel | string | No data | Shown in the middle of the chart, in place of the content in the hole, when there is nothing to draw: no data, or every value 0. The ring is then drawn in the neutral empty color. |
| PieChart.labels | Partial<ChartLabels> | English words | Replaces the words that the chart gives screen readers, each one on its own. Pass their translation when the application is in another language. |
| PieChart.className | string | unset | Additional classes on the figure. |
| PieChartDatum.id | string | required | Stable key of the share. It tells the chart which share is which when the data changes. |
| PieChartDatum.label | string | required | Name shown in the legend and the tooltip. |
| PieChartDatum.value | number | required | Size of the share. A negative or non-finite value counts as 0. |
| PieChartDatum.color | string | palette color | A CSS color that replaces the palette color of this share. |
| PieChartDatum.empty | boolean | false | Marks an unfilled share. It takes the neutral empty color and does not use up a palette color. |
| ChartLabels.roleDescription | string | chart | What screen readers call the chart. |
| ChartLabels.keyboardHint | string | Use the arrow keys to move between data points. | Read after the description of the chart while it is a focus stop. An empty text leaves the hint out. |
PieChartProps and PieChartDatum export these contracts, and ChartLabels comes
from the chart core, `@/siteplane/components/chart`, which installs with the
chart. The chart renders a figure with the plot and the legend; it takes no
native props or ref beyond `className` and `aria-describedby`. A share that
names no `color` takes a shade of the brand color, from strong to light, spread
over the filled shares. `palette="categorical"` gives each share its own chart
color. Both palettes have eight colors and repeat them from the ninth filled
share, except that the last share never takes the color of the first, which it
meets where the ring closes; a `color` on a share always wins, and an `empty`
share takes no palette color.

[Read the installed source](/source/pie-chart).

## Usage notes

Use a pie chart for a few shares of one total. With more than five or six shares, or when readers must compare values precisely, show a [Table](/docs/components/table/) or a bar chart instead. Shares are drawn clockwise from the top in the order of `data`, so sort them in a way readers can follow.

The chart is one focus stop. Keyboard focus makes the first share active; the Left and Right arrow keys step through the shares and wrap around, Home and End jump to the first and the last share, and Escape closes the tooltip. A tap or pen press pins the share under it until the next press elsewhere or Escape. Screen readers meet the ring as a chart named by `label` and hear a hint that the arrow keys move between the data points. `labels` replaces the hint and the role description, for example with their translation. While the keyboard or a tap moves between the shares, a polite live region reads each share and its value. The legend names every share and reads its formatted value to screen readers, so the color never carries the information alone; without the legend, the same text describes the chart. A chart with nothing in it is not a focus stop, and `emptyLabel` describes it. Legend entries are not focus stops, and the legend does not toggle shares.

Nothing fades while a share is active: in the brand shades, a faded share would look like another one. With `popOut`, a share that steps out reaches up to 10 px beyond the box of the chart, so an ancestor that clips its overflow close to the chart cuts it off. A share that is the whole total has nowhere to step and stays in place.

The chart has no click or selection API. New data animates with the [Motion](/docs/components/motion/) tokens (`--motion-duration-value` and `--motion-ease-standard`). A share that pops out glides with the follow motion (`--motion-duration-follow`), and the tooltip card trails the share with `--motion-duration-follow-trail`. All of it is instant under reduced motion: the system setting, `html` with `data-motion-reduce="true"` or `SiteplaneMotionProvider preset="reduced"`. The tooltip comes from [Tooltip](/docs/components/tooltip/). The chart opens and closes it itself, so it needs no `TooltipProvider`, which only shares hover delays between tooltips.
