Siteplane UI

Pie Chart

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

Markdown
Use the arrow keys to move between data points.
  • Documents: 200 GB
  • Images: 350 GB
  • Video: 100 GB
  • Other: 120 GB
  • Free: 230 GB
"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

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

Complete the one-time setup first. Registry JSON lists source files and dependencies.

For this page’s examples, also install:

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

Usage

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.

Use the arrow keys to move between data points.
  • Enterprise: $48,200
  • Business: $31,500
  • Team: $18,900
  • Starter: $7,400
"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.

205Tickets
Use the arrow keys to move between data points.
  • Open: 128
  • Pending: 54
  • On hold: 23
$2,486,000Revenue
Use the arrow keys to move between data points.
  • Enterprise: $1,486,000
  • Team: $742,500
  • Starter: $257,500
"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.

Use the arrow keys to move between data points.
Used: 7.2 GB; Free: 2.8 GB.

Storage

7.2 GB of 10 GB used

"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.

Use the arrow keys to move between data points.
  • Organic search: 38%
  • Direct: 24%
  • Referral: 18%
  • Social: 12%
  • Email: 8%
"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.

Use the arrow keys to move between data points.
  • North America: $412K
  • Europe: $268K
  • Asia Pacific: $174K
  • Latin America: $61K
"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, and the tooltip stays on the share it was on.

Use the arrow keys to move between data points.
  • Search: 5,265 visitors · 42%
  • Direct: 3,120 visitors · 25%
  • Social: 1,755 visitors · 14%
  • Referral: 1,365 visitors · 11%
  • Email: 975 visitors · 8%
"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.

120 px

Use the arrow keys to move between data points.
  • Documents: 200
  • Images: 350
  • Video: 100
  • Other: 120
  • Free: 230

200 px

Use the arrow keys to move between data points.
  • Documents: 200
  • Images: 350
  • Video: 100
  • Other: 120
  • Free: 230

280 px

Use the arrow keys to move between data points.
  • Documents: 200
  • Images: 350
  • Video: 100
  • Other: 120
  • Free: 230
"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

Component properties
PropTypeDefaultDescription
PieChart.datareadonly PieChartDatum[]requiredShares of the total, drawn clockwise from the top in the order given.
PieChart.labelstringrequiredAccessible name of the chart.
PieChart.aria-describedbystringunsetId of an element that describes the chart, read after the description that the chart gives itself.
PieChart.variantdonut | piedonutdonut draws the shares as a ring around a hole; pie fills the whole disc with a wedge per share.
PieChart.centerValuenumberunsetA 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.centerLabelstringunsetA caption under centerValue: one line of 12 px muted text that ends in an ellipsis where the hole is too narrow.
PieChart.centerFormatIntl.NumberFormatOptionsunsetHow centerValue is written in locale, such as a currency. The chart adds the compact notation when the figure needs it.
PieChart.centerReactNodeunsetContent 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.legendbooleantrueLists 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.palettebrand | categoricalbrandbrand 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.popOutbooleanfalseSteps 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.sizenumber200Diameter of the chart in pixels.
PieChart.formatValue(value: number) => stringlocale number (1,234.5)Formats the value in the tooltip and in the text that screen readers read for each share.
PieChart.localestringen-USLocale of the numbers.
PieChart.emptyLabelstringNo dataShown 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.labelsPartial<ChartLabels>English wordsReplaces the words that the chart gives screen readers, each one on its own. Pass their translation when the application is in another language.
PieChart.classNamestringunsetAdditional classes on the figure.
PieChartDatum.idstringrequiredStable key of the share. It tells the chart which share is which when the data changes.
PieChartDatum.labelstringrequiredName shown in the legend and the tooltip.
PieChartDatum.valuenumberrequiredSize of the share. A negative or non-finite value counts as 0.
PieChartDatum.colorstringpalette colorA CSS color that replaces the palette color of this share.
PieChartDatum.emptybooleanfalseMarks an unfilled share. It takes the neutral empty color and does not use up a palette color.
ChartLabels.roleDescriptionstringchartWhat screen readers call the chart.
ChartLabels.keyboardHintstringUse 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.

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 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 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. The chart opens and closes it itself, so it needs no TooltipProvider, which only shares hover delays between tooltips.