# Pie Chart Interactive

A storage breakdown donut with a device switch, hover-linked list and lifted slices, plus a linear usage bar.

Pie Chart Interactive is an open-source chart from suiss/ui for React, React Native, SwiftUI and Jetpack Compose. The @esuiss/ui CLI copies its source into your project, so you own the code.

Source: https://ui.suiss.app/docs/charts/pie-chart-interactive

**React**

**Example**

```tsx title="components/charts/pie-chart-interactive.tsx"
"use client"

import * as React from "react"
import { Pie, PieChart, Sector } from "recharts"

import { Card, CardContent, CardHeader } from "@/components/ui/card"
import { type ChartConfig, ChartContainer } from "@/components/ui/chart"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

type Device = "phone" | "cloud"

type Kind =
  | "apps"
  | "photos"
  | "system"
  | "backups"
  | "drive"
  | "messages"
  | "mail"
  | "other"
  | "free"

const KINDS: Kind[] = [
  "apps",
  "photos",
  "system",
  "backups",
  "drive",
  "messages",
  "mail",
  "other",
  "free",
]

const STORAGE: Record<
  Device,
  { label: string; total: number; used: Partial<Record<Kind, number>> }
> = {
  phone: {
    label: "Phone",
    total: 256,
    used: { apps: 62.4, photos: 48.1, system: 25.8, messages: 9.8, other: 6.3 },
  },
  cloud: {
    label: "Cloud",
    total: 200,
    used: {
      photos: 96.2,
      backups: 31.4,
      drive: 18.7,
      messages: 7.9,
      mail: 2.3,
    },
  },
}

const chartConfig = {
  apps: { label: "Apps", color: "var(--color-red-500)" },
  photos: { label: "Photos", color: "var(--chart-4)" },
  system: { label: "System", color: "var(--chart-5)" },
  backups: { label: "Backups", color: "var(--color-purple-500)" },
  drive: { label: "Drive", color: "var(--chart-1)" },
  messages: { label: "Messages", color: "var(--color-green-500)" },
  mail: { label: "Mail", color: "var(--color-cyan-500)" },
  other: { label: "Other", color: "var(--color-yellow-500)" },
  free: { label: "Free", color: "var(--secondary)" },
} satisfies ChartConfig

const MORPH_DURATION = 700
const LIFT_DURATION = 260
const LIFT = 7

const sizeFormat = new Intl.NumberFormat("en-US", {
  minimumFractionDigits: 1,
  maximumFractionDigits: 1,
})

function sizes(device: Device) {
  const { total, used } = STORAGE[device]
  const taken = Object.values(used).reduce((sum, value) => sum + value, 0)
  return KINDS.map((kind) =>
    kind === "free" ? total - taken : (used[kind] ?? 0)
  )
}

function useMorph(target: number[], duration: number) {
  const [values, setValues] = React.useState(target)
  const valuesRef = React.useRef(values)
  valuesRef.current = values

  React.useEffect(() => {
    const from = valuesRef.current
    if (target.every((value, index) => value === from[index])) return
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      setValues(target)
      return
    }
    const start = performance.now()
    let frame = 0
    const step = () => {
      const progress = Math.min(1, (performance.now() - start) / duration)
      const eased =
        progress < 0.5 ? 4 * progress ** 3 : 1 - (-2 * progress + 2) ** 3 / 2
      setValues(
        target.map((value, index) => {
          const origin = from[index] ?? value
          return origin + (value - origin) * eased
        })
      )
      if (progress < 1) frame = requestAnimationFrame(step)
    }
    frame = requestAnimationFrame(step)
    return () => cancelAnimationFrame(frame)
  }, [target, duration])

  return values
}

export function PieChartInteractive() {
  const [device, setDevice] = React.useState<Device>("phone")
  const [active, setActive] = React.useState<Kind | null>(null)
  const sourceRef = React.useRef<"pie" | "list" | null>(null)

  const point = (kind: Kind) => {
    sourceRef.current = "list"
    setActive(kind)
  }
  const release = () => {
    sourceRef.current = null
    setActive(null)
  }
  const target = React.useMemo(() => sizes(device), [device])
  const values = useMorph(target, MORPH_DURATION)
  const liftTarget = React.useMemo(
    () => KINDS.map((kind) => (kind === active ? 1 : 0)),
    [active]
  )
  const lift = useMorph(liftTarget, LIFT_DURATION)
  const sizeOf = (kind: Kind) => values[KINDS.indexOf(kind)] ?? 0
  const total = values.reduce((sum, value) => sum + value, 0)
  const used = total - sizeOf("free")
  const activeValue = active ? sizeOf(active) : used
  const targetOf = (kind: Kind) => target[KINDS.indexOf(kind)] ?? 0
  const targetTotal = target.reduce((sum, value) => sum + value, 0)
  const targetValue = active ? targetOf(active) : targetTotal - targetOf("free")
  const data = React.useMemo(
    () =>
      KINDS.map((kind, index) => ({
        kind,
        size: Math.max(0, values[index] ?? 0),
        fill: `var(--color-${kind})`,
      })),
    [values]
  )

  return (
    <Card variant="filled" size="sm" className="[--card-spacing:--spacing(5)]">
      <CardHeader className="flex flex-wrap items-start justify-between gap-4">
        <div className="flex min-w-48 flex-1 flex-col gap-0.5">
          <p className="text-lg font-semibold">
            {STORAGE[device].label} storage
          </p>
          <p className="text-sm text-muted-foreground tabular-nums">
            {sizeFormat.format(used)} GB of {STORAGE[device].total} GB used
          </p>
        </div>
        <ToggleGroup
          spacing={0}
          value={[device]}
          onValueChange={(value: string[]) => {
            if (!value[0]) return
            setDevice(value[0] as Device)
            setActive(null)
          }}
          aria-label="Device"
        >
          {(Object.keys(STORAGE) as Device[]).map((key) => (
            <ToggleGroupItem key={key} value={key} size="sm" className="px-5">
              {STORAGE[key].label}
            </ToggleGroupItem>
          ))}
        </ToggleGroup>
      </CardHeader>
      <CardContent className="flex flex-col gap-5">
        <div
          aria-hidden
          className="flex h-2.5 overflow-hidden rounded-full bg-secondary"
        >
          {KINDS.filter((kind) => kind !== "free").map((kind) => {
            const share = (Math.max(0, sizeOf(kind)) / total) * 100
            return (
              <span
                key={kind}
                onPointerEnter={() => point(kind)}
                onPointerLeave={release}
                data-dimmed={active && active !== kind ? "" : undefined}
                className="h-full bg-(--swatch) transition-opacity duration-200 data-dimmed:opacity-35"
                style={
                  {
                    width: `${share}%`,
                    marginInlineEnd: Math.min(2, share * 4),
                    "--swatch": chartConfig[kind].color,
                  } as React.CSSProperties
                }
              />
            )
          })}
        </div>
        <div className="flex flex-wrap items-center gap-6">
          <div className="relative mx-auto size-70 max-w-full shrink-0">
            <ChartContainer
              config={chartConfig}
              className="aspect-square size-full"
            >
              <PieChart accessibilityLayer={false}>
                <Pie
                  data={data}
                  dataKey="size"
                  nameKey="kind"
                  innerRadius="64%"
                  outerRadius="88%"
                  startAngle={90}
                  endAngle={-270}
                  stroke="var(--muted)"
                  strokeWidth={3}
                  cornerRadius={4}
                  isAnimationActive={false}
                  onMouseEnter={(_, index) => {
                    const kind = KINDS[index]
                    if (!kind) return
                    sourceRef.current = "pie"
                    setActive(kind)
                  }}
                  onMouseLeave={() => {
                    if (sourceRef.current === "pie") release()
                  }}
                  shape={(props, index) => (
                    <Sector
                      {...props}
                      outerRadius={
                        (props.outerRadius ?? 0) +
                        (typeof index === "number" ? (lift[index] ?? 0) : 0) *
                          LIFT
                      }
                    />
                  )}
                />
              </PieChart>
            </ChartContainer>
            <p className="sr-only" aria-live="polite">
              {active ? chartConfig[active].label : "Used"}:{" "}
              {sizeFormat.format(targetValue)} GB,{" "}
              {Math.round((targetValue / targetTotal) * 100)}%
            </p>
            <div
              aria-hidden
              className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center"
            >
              <span className="text-sm text-muted-foreground">
                {active ? chartConfig[active].label : "Used"}
              </span>
              <span className="text-3xl font-semibold tracking-tight tabular-nums">
                {sizeFormat.format(activeValue)} GB
              </span>
              <span className="text-sm text-muted-foreground tabular-nums">
                {Math.round((activeValue / total) * 100)}%
              </span>
            </div>
          </div>
          <ul className="flex min-w-0 flex-[1_1_15rem] flex-col">
            {KINDS.map((kind) => {
              const present = (target[KINDS.indexOf(kind)] ?? 0) > 0
              return (
                <li
                  key={kind}
                  data-present={present ? "" : undefined}
                  inert={!present}
                  className="grid grid-rows-[0fr] opacity-0 transition-[grid-template-rows,opacity] duration-500 ease-[cubic-bezier(0.45,0,0.2,1)] data-present:grid-rows-[1fr] data-present:opacity-100"
                >
                  <div className="min-h-0 overflow-hidden">
                    <div
                      onPointerEnter={() => point(kind)}
                      onPointerLeave={release}
                      data-active={active === kind ? "" : undefined}
                      className="flex items-center gap-2.5 rounded-lg px-2 py-2.25 transition-colors duration-150 data-active:bg-secondary"
                    >
                      <span
                        className="size-2.5 shrink-0 rounded-full bg-(--swatch) data-[kind=free]:ring-1 data-[kind=free]:ring-input data-[kind=free]:ring-inset"
                        data-kind={kind}
                        style={
                          {
                            "--swatch": chartConfig[kind].color,
                          } as React.CSSProperties
                        }
                      />
                      <span className="flex-1 text-base">
                        {chartConfig[kind].label}
                      </span>
                      <span className="text-base text-muted-foreground tabular-nums">
                        {sizeFormat.format(Math.max(0, sizeOf(kind)))} GB
                      </span>
                    </div>
                  </div>
                </li>
              )
            })}
          </ul>
        </div>
      </CardContent>
    </Card>
  )
}
```

A storage breakdown with a Phone and Cloud switch, a stacked usage bar, a donut whose hovered slice lifts out and a list linked to it. The center shows the used total or the hovered category, and the slices morph when you switch devices. Use it as a reference for a rich part-to-whole view with linked controls.

## Installation

### CLI

```bash title="Terminal"
npx shadcn@latest add @esuiss/pie-chart-interactive
```

Works in any project with a `components.json`. See [Installation](https://ui.suiss.app/docs/installation#add-components) for the one-line registry setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add charts pie-chart-interactive
```

### Manual

1. Install the dependencies:

```bash title="Terminal"
npm install recharts
```

2. Add the pieces it builds on: [Card](https://ui.suiss.app/docs/components/card), [Chart](https://ui.suiss.app/docs/components/chart), [Toggle Group](https://ui.suiss.app/docs/components/toggle-group).

3. Copy the source into `components/charts/pie-chart-interactive.tsx`:

```tsx title="components/charts/pie-chart-interactive.tsx"
"use client"

import * as React from "react"
import { Pie, PieChart, Sector } from "recharts"

import { Card, CardContent, CardHeader } from "@/components/ui/card"
import { type ChartConfig, ChartContainer } from "@/components/ui/chart"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

type Device = "phone" | "cloud"

type Kind =
  | "apps"
  | "photos"
  | "system"
  | "backups"
  | "drive"
  | "messages"
  | "mail"
  | "other"
  | "free"

const KINDS: Kind[] = [
  "apps",
  "photos",
  "system",
  "backups",
  "drive",
  "messages",
  "mail",
  "other",
  "free",
]

const STORAGE: Record<
  Device,
  { label: string; total: number; used: Partial<Record<Kind, number>> }
> = {
  phone: {
    label: "Phone",
    total: 256,
    used: { apps: 62.4, photos: 48.1, system: 25.8, messages: 9.8, other: 6.3 },
  },
  cloud: {
    label: "Cloud",
    total: 200,
    used: {
      photos: 96.2,
      backups: 31.4,
      drive: 18.7,
      messages: 7.9,
      mail: 2.3,
    },
  },
}

const chartConfig = {
  apps: { label: "Apps", color: "var(--color-red-500)" },
  photos: { label: "Photos", color: "var(--chart-4)" },
  system: { label: "System", color: "var(--chart-5)" },
  backups: { label: "Backups", color: "var(--color-purple-500)" },
  drive: { label: "Drive", color: "var(--chart-1)" },
  messages: { label: "Messages", color: "var(--color-green-500)" },
  mail: { label: "Mail", color: "var(--color-cyan-500)" },
  other: { label: "Other", color: "var(--color-yellow-500)" },
  free: { label: "Free", color: "var(--secondary)" },
} satisfies ChartConfig

const MORPH_DURATION = 700
const LIFT_DURATION = 260
const LIFT = 7

const sizeFormat = new Intl.NumberFormat("en-US", {
  minimumFractionDigits: 1,
  maximumFractionDigits: 1,
})

function sizes(device: Device) {
  const { total, used } = STORAGE[device]
  const taken = Object.values(used).reduce((sum, value) => sum + value, 0)
  return KINDS.map((kind) =>
    kind === "free" ? total - taken : (used[kind] ?? 0)
  )
}

function useMorph(target: number[], duration: number) {
  const [values, setValues] = React.useState(target)
  const valuesRef = React.useRef(values)
  valuesRef.current = values

  React.useEffect(() => {
    const from = valuesRef.current
    if (target.every((value, index) => value === from[index])) return
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      setValues(target)
      return
    }
    const start = performance.now()
    let frame = 0
    const step = () => {
      const progress = Math.min(1, (performance.now() - start) / duration)
      const eased =
        progress < 0.5 ? 4 * progress ** 3 : 1 - (-2 * progress + 2) ** 3 / 2
      setValues(
        target.map((value, index) => {
          const origin = from[index] ?? value
          return origin + (value - origin) * eased
        })
      )
      if (progress < 1) frame = requestAnimationFrame(step)
    }
    frame = requestAnimationFrame(step)
    return () => cancelAnimationFrame(frame)
  }, [target, duration])

  return values
}

export function PieChartInteractive() {
  const [device, setDevice] = React.useState<Device>("phone")
  const [active, setActive] = React.useState<Kind | null>(null)
  const sourceRef = React.useRef<"pie" | "list" | null>(null)

  const point = (kind: Kind) => {
    sourceRef.current = "list"
    setActive(kind)
  }
  const release = () => {
    sourceRef.current = null
    setActive(null)
  }
  const target = React.useMemo(() => sizes(device), [device])
  const values = useMorph(target, MORPH_DURATION)
  const liftTarget = React.useMemo(
    () => KINDS.map((kind) => (kind === active ? 1 : 0)),
    [active]
  )
  const lift = useMorph(liftTarget, LIFT_DURATION)
  const sizeOf = (kind: Kind) => values[KINDS.indexOf(kind)] ?? 0
  const total = values.reduce((sum, value) => sum + value, 0)
  const used = total - sizeOf("free")
  const activeValue = active ? sizeOf(active) : used
  const targetOf = (kind: Kind) => target[KINDS.indexOf(kind)] ?? 0
  const targetTotal = target.reduce((sum, value) => sum + value, 0)
  const targetValue = active ? targetOf(active) : targetTotal - targetOf("free")
  const data = React.useMemo(
    () =>
      KINDS.map((kind, index) => ({
        kind,
        size: Math.max(0, values[index] ?? 0),
        fill: `var(--color-${kind})`,
      })),
    [values]
  )

  return (
    <Card variant="filled" size="sm" className="[--card-spacing:--spacing(5)]">
      <CardHeader className="flex flex-wrap items-start justify-between gap-4">
        <div className="flex min-w-48 flex-1 flex-col gap-0.5">
          <p className="text-lg font-semibold">
            {STORAGE[device].label} storage
          </p>
          <p className="text-sm text-muted-foreground tabular-nums">
            {sizeFormat.format(used)} GB of {STORAGE[device].total} GB used
          </p>
        </div>
        <ToggleGroup
          spacing={0}
          value={[device]}
          onValueChange={(value: string[]) => {
            if (!value[0]) return
            setDevice(value[0] as Device)
            setActive(null)
          }}
          aria-label="Device"
        >
          {(Object.keys(STORAGE) as Device[]).map((key) => (
            <ToggleGroupItem key={key} value={key} size="sm" className="px-5">
              {STORAGE[key].label}
            </ToggleGroupItem>
          ))}
        </ToggleGroup>
      </CardHeader>
      <CardContent className="flex flex-col gap-5">
        <div
          aria-hidden
          className="flex h-2.5 overflow-hidden rounded-full bg-secondary"
        >
          {KINDS.filter((kind) => kind !== "free").map((kind) => {
            const share = (Math.max(0, sizeOf(kind)) / total) * 100
            return (
              <span
                key={kind}
                onPointerEnter={() => point(kind)}
                onPointerLeave={release}
                data-dimmed={active && active !== kind ? "" : undefined}
                className="h-full bg-(--swatch) transition-opacity duration-200 data-dimmed:opacity-35"
                style={
                  {
                    width: `${share}%`,
                    marginInlineEnd: Math.min(2, share * 4),
                    "--swatch": chartConfig[kind].color,
                  } as React.CSSProperties
                }
              />
            )
          })}
        </div>
        <div className="flex flex-wrap items-center gap-6">
          <div className="relative mx-auto size-70 max-w-full shrink-0">
            <ChartContainer
              config={chartConfig}
              className="aspect-square size-full"
            >
              <PieChart accessibilityLayer={false}>
                <Pie
                  data={data}
                  dataKey="size"
                  nameKey="kind"
                  innerRadius="64%"
                  outerRadius="88%"
                  startAngle={90}
                  endAngle={-270}
                  stroke="var(--muted)"
                  strokeWidth={3}
                  cornerRadius={4}
                  isAnimationActive={false}
                  onMouseEnter={(_, index) => {
                    const kind = KINDS[index]
                    if (!kind) return
                    sourceRef.current = "pie"
                    setActive(kind)
                  }}
                  onMouseLeave={() => {
                    if (sourceRef.current === "pie") release()
                  }}
                  shape={(props, index) => (
                    <Sector
                      {...props}
                      outerRadius={
                        (props.outerRadius ?? 0) +
                        (typeof index === "number" ? (lift[index] ?? 0) : 0) *
                          LIFT
                      }
                    />
                  )}
                />
              </PieChart>
            </ChartContainer>
            <p className="sr-only" aria-live="polite">
              {active ? chartConfig[active].label : "Used"}:{" "}
              {sizeFormat.format(targetValue)} GB,{" "}
              {Math.round((targetValue / targetTotal) * 100)}%
            </p>
            <div
              aria-hidden
              className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center"
            >
              <span className="text-sm text-muted-foreground">
                {active ? chartConfig[active].label : "Used"}
              </span>
              <span className="text-3xl font-semibold tracking-tight tabular-nums">
                {sizeFormat.format(activeValue)} GB
              </span>
              <span className="text-sm text-muted-foreground tabular-nums">
                {Math.round((activeValue / total) * 100)}%
              </span>
            </div>
          </div>
          <ul className="flex min-w-0 flex-[1_1_15rem] flex-col">
            {KINDS.map((kind) => {
              const present = (target[KINDS.indexOf(kind)] ?? 0) > 0
              return (
                <li
                  key={kind}
                  data-present={present ? "" : undefined}
                  inert={!present}
                  className="grid grid-rows-[0fr] opacity-0 transition-[grid-template-rows,opacity] duration-500 ease-[cubic-bezier(0.45,0,0.2,1)] data-present:grid-rows-[1fr] data-present:opacity-100"
                >
                  <div className="min-h-0 overflow-hidden">
                    <div
                      onPointerEnter={() => point(kind)}
                      onPointerLeave={release}
                      data-active={active === kind ? "" : undefined}
                      className="flex items-center gap-2.5 rounded-lg px-2 py-2.25 transition-colors duration-150 data-active:bg-secondary"
                    >
                      <span
                        className="size-2.5 shrink-0 rounded-full bg-(--swatch) data-[kind=free]:ring-1 data-[kind=free]:ring-input data-[kind=free]:ring-inset"
                        data-kind={kind}
                        style={
                          {
                            "--swatch": chartConfig[kind].color,
                          } as React.CSSProperties
                        }
                      />
                      <span className="flex-1 text-base">
                        {chartConfig[kind].label}
                      </span>
                      <span className="text-base text-muted-foreground tabular-nums">
                        {sizeFormat.format(Math.max(0, sizeOf(kind)))} GB
                      </span>
                    </div>
                  </div>
                </li>
              )
            })}
          </ul>
        </div>
      </CardContent>
    </Card>
  )
}
```

4. Update the import paths to match your project.

## Usage

```tsx
import { PieChartInteractive } from "@/components/charts/pie-chart-interactive"
```

```tsx
<PieChartInteractive />
```

Replace `STORAGE` with your own sources, each with a total and the size of every category, and list all categories in `KINDS`. Add a label and color for each category to `chartConfig`; the free space is computed from the total. Add another source to `STORAGE` to get another toggle.

## Notes

Highlighting works with a mouse over the slices, the bar or the list, but list rows aren't focusable, so keyboard users only get the totals. Changes in the center readout are announced through a live region, and the morph animation is skipped when reduced motion is preferred.

**React Native, SwiftUI, Jetpack Compose**

![Preview](https://ui.suiss.app/previews/charts-pie--interactive.light.webp)

```tsx title="uniwind/components/charts/pie-chart-interactive.tsx"
import { cn } from "cn"
import * as React from "react"
import { Pressable, View } from "react-native"
import { Card, CardContent, CardHeader } from "@/components/ui/card"
import {
  type ChartConfig,
  ChartContainer,
  Pie,
  PieChart,
} from "@/components/ui/chart"
import { Text } from "@/components/ui/text"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

type Device = "phone" | "cloud"

type Kind =
  | "apps"
  | "photos"
  | "system"
  | "backups"
  | "drive"
  | "messages"
  | "mail"
  | "other"
  | "free"

const KINDS: Kind[] = [
  "apps",
  "photos",
  "system",
  "backups",
  "drive",
  "messages",
  "mail",
  "other",
  "free",
]

const STORAGE: Record<
  Device,
  { label: string; total: number; used: Partial<Record<Kind, number>> }
> = {
  phone: {
    label: "Phone",
    total: 256,
    used: { apps: 62.4, photos: 48.1, system: 25.8, messages: 9.8, other: 6.3 },
  },
  cloud: {
    label: "Cloud",
    total: 200,
    used: {
      photos: 96.2,
      backups: 31.4,
      drive: 18.7,
      messages: 7.9,
      mail: 2.3,
    },
  },
}

const chartConfig = {
  apps: { label: "Apps", color: "red-500" },
  photos: { label: "Photos", color: "chart-4" },
  system: { label: "System", color: "chart-5" },
  backups: { label: "Backups", color: "purple-500" },
  drive: { label: "Drive", color: "chart-1" },
  messages: { label: "Messages", color: "green-500" },
  mail: { label: "Mail", color: "cyan-500" },
  other: { label: "Other", color: "yellow-500" },
  free: { label: "Free", color: "secondary" },
} satisfies ChartConfig

const SWATCH: Record<Kind, string> = {
  apps: "bg-red-500",
  photos: "bg-orange-500",
  system: "bg-zinc-500",
  backups: "bg-purple-500",
  drive: "bg-blue-500",
  messages: "bg-green-500",
  mail: "bg-cyan-500",
  other: "bg-yellow-500",
  free: "bg-secondary border border-input",
}

const LIFT = 7

function formatSize(value: number) {
  try {
    return new Intl.NumberFormat("en-US", {
      minimumFractionDigits: 1,
      maximumFractionDigits: 1,
    }).format(value)
  } catch {
    return value.toFixed(1)
  }
}

function sizes(device: Device) {
  const { total, used } = STORAGE[device]
  const taken = Object.values(used).reduce((sum, value) => sum + value, 0)
  return KINDS.map((kind) =>
    kind === "free" ? total - taken : (used[kind] ?? 0)
  )
}

export function PieChartInteractive() {
  const [device, setDevice] = React.useState<Device>("phone")
  const [active, setActive] = React.useState<Kind | null>(null)
  const values = React.useMemo(() => sizes(device), [device])
  const sizeOf = (kind: Kind) => values[KINDS.indexOf(kind)] ?? 0
  const total = values.reduce((sum, value) => sum + value, 0)
  const used = total - sizeOf("free")
  const activeValue = active ? sizeOf(active) : used
  const data = KINDS.map((kind, index) => ({
    kind,
    size: Math.max(0, values[index] ?? 0),
  }))

  return (
    <Card variant="filled" size="sm">
      <CardHeader className="flex-row flex-wrap items-start justify-between gap-4">
        <View className="min-w-48 grow gap-0.5">
          <Text className="text-lg font-semibold">
            {`${STORAGE[device].label} storage`}
          </Text>
          <Text className="text-sm text-muted-foreground tabular-nums">
            {`${formatSize(used)} GB of ${STORAGE[device].total} GB used`}
          </Text>
        </View>
        <ToggleGroup
          spacing={0}
          value={[device]}
          onValueChange={(value) => {
            if (!value[0]) return
            setDevice(value[0] as Device)
            setActive(null)
          }}
          accessibilityLabel="Device"
        >
          {(Object.keys(STORAGE) as Device[]).map((key) => (
            <ToggleGroupItem key={key} value={key} size="sm" className="px-5">
              <Text>{STORAGE[key].label}</Text>
            </ToggleGroupItem>
          ))}
        </ToggleGroup>
      </CardHeader>
      <CardContent className="gap-5">
        <View
          accessibilityElementsHidden
          importantForAccessibility="no-hide-descendants"
          className="h-2.5 flex-row overflow-hidden rounded-full bg-secondary"
        >
          {KINDS.filter((kind) => kind !== "free").map((kind) => {
            const share = (Math.max(0, sizeOf(kind)) / total) * 100
            return (
              <View
                key={kind}
                style={{
                  width: `${share}%`,
                  marginEnd: Math.min(2, share * 4),
                }}
                className={cn(
                  "h-full",
                  SWATCH[kind],
                  active && active !== kind && "opacity-35"
                )}
              />
            )
          })}
        </View>
        <View className="flex-row flex-wrap items-center justify-center gap-6">
          <View className="size-70 max-w-full">
            <View
              pointerEvents="none"
              accessibilityLiveRegion="polite"
              className="absolute inset-0 items-center justify-center"
            >
              <Text className="text-sm text-muted-foreground">
                {active ? chartConfig[active].label : "Used"}
              </Text>
              <Text className="text-3xl font-semibold tracking-tight tabular-nums">
                {`${formatSize(activeValue)} GB`}
              </Text>
              <Text className="text-sm text-muted-foreground tabular-nums">
                {`${Math.round((activeValue / total) * 100)}%`}
              </Text>
            </View>
            <ChartContainer
              config={chartConfig}
              accessibilityLabel={`${STORAGE[device].label} storage`}
              className="aspect-square size-full"
            >
              <PieChart
                key={device}
                onActiveIndexChange={(index) =>
                  setActive(index === null ? null : (KINDS[index] ?? null))
                }
              >
                <Pie
                  isAnimationActive={false}
                  data={data}
                  dataKey="size"
                  nameKey="kind"
                  innerRadius="64%"
                  outerRadius="88%"
                  startAngle={90}
                  endAngle={-270}
                  stroke="muted"
                  strokeWidth={3}
                  cornerRadius={4}
                  shape={(sector, index) => ({
                    ...sector,
                    outerRadius:
                      sector.outerRadius + (KINDS[index] === active ? LIFT : 0),
                  })}
                />
              </PieChart>
            </ChartContainer>
          </View>
          <View className="min-w-60 grow basis-60">
            {KINDS.filter((kind) => sizeOf(kind) > 0).map((kind) => (
              <Pressable
                key={kind}
                role="button"
                accessibilityState={{ selected: active === kind }}
                onPress={() =>
                  setActive((current) => (current === kind ? null : kind))
                }
                className={cn(
                  "flex-row items-center gap-2.5 rounded-lg px-2 py-2.25",
                  active === kind && "bg-secondary"
                )}
              >
                <View
                  className={cn("size-2.5 shrink-0 rounded-full", SWATCH[kind])}
                />
                <Text className="grow text-base">
                  {chartConfig[kind].label}
                </Text>
                <Text className="text-base text-muted-foreground tabular-nums">
                  {`${formatSize(Math.max(0, sizeOf(kind)))} GB`}
                </Text>
              </Pressable>
            ))}
          </View>
        </View>
      </CardContent>
    </Card>
  )
}
```

```swift title="swift/Charts/PieChartInteractive.swift"
import Charts
import SwiftUI

private enum Device: String, CaseIterable {
  case phone, cloud

  var label: String {
    switch self {
    case .phone: "Phone"
    case .cloud: "Cloud"
    }
  }

  var total: Double {
    switch self {
    case .phone: 256
    case .cloud: 200
    }
  }

  var used: [String: Double] {
    switch self {
    case .phone: ["apps": 62.4, "photos": 48.1, "system": 25.8, "messages": 9.8, "other": 6.3]
    case .cloud: ["photos": 96.2, "backups": 31.4, "drive": 18.7, "messages": 7.9, "mail": 2.3]
    }
  }

  var sizes: [Double] {
    let taken = used.values.reduce(0, +)
    return kinds.map { $0 == "free" ? total - taken : used[$0] ?? 0 }
  }
}

private let kinds = ["apps", "photos", "system", "backups", "drive", "messages", "mail", "other", "free"]

private let chartConfig: ChartConfig = [
  "apps": .init("Apps", color: Palette.red500),
  "photos": .init("Photos", color: EsuissColor.chart4),
  "system": .init("System", color: EsuissColor.chart5),
  "backups": .init("Backups", color: Palette.purple500),
  "drive": .init("Drive", color: EsuissColor.chart1),
  "messages": .init("Messages", color: Palette.green500),
  "mail": .init("Mail", color: Palette.cyan500),
  "other": .init("Other", color: Palette.yellow500),
  "free": .init("Free", color: EsuissColor.secondary),
]

public struct PieChartInteractive: View {
  @State private var device: Device = .phone
  @State private var active: Int?
  @Environment(\.locale) private var locale
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  public init() {}

  private var deviceBinding: Binding<Device?> {
    Binding(
      get: { device },
      set: { value in
        guard let value, value != device else { return }
        withAnimation(reduceMotion ? nil : .timingCurve(0.65, 0, 0.35, 1, duration: 0.7)) {
          device = value
          active = nil
        }
      }
    )
  }

  private var activeBinding: Binding<Int?> {
    Binding(
      get: { active },
      set: { value in
        withAnimation(reduceMotion ? nil : .easeOut(duration: 0.26)) { active = value }
      }
    )
  }

  private func size(_ value: Double) -> String {
    max(0, value).formatted(.number.precision(.fractionLength(1)).locale(locale))
  }

  private func gigabytes(_ value: Double) -> String {
    "\(size(value)) GB"
  }

  public var body: some View {
    let sizes = device.sizes
    let total = sizes.reduce(0, +)
    let used = total - (sizes.last ?? 0)
    let value = active.map { sizes[$0] } ?? used
    Card(.filled, size: .sm) {
      HStack(alignment: .top, spacing: 16) {
        VStack(alignment: .leading, spacing: 2) {
          Text("\(device.label) storage")
            .textStyle(.lg, weight: .semibold)
          Text("\(size(used)) GB of \(Int(device.total)) GB used")
            .textStyle(.sm)
            .monospacedDigit()
            .foregroundStyle(EsuissColor.mutedForeground)
        }
        .frame(maxWidth: .infinity, alignment: .leading)
        ToggleGroup(selection: deviceBinding, size: .sm, spacing: 0) {
          ForEach(Device.allCases, id: \.self) { item in
            ToggleGroupItem(LocalizedStringKey(item.label), value: item)
          }
        }
        .accessibilityLabel("Device")
      }
      .padding(.horizontal, 16)
      CardContent {
        VStack(spacing: 20) {
          storageBar(sizes: sizes, total: total)
          ChartContainer(config: chartConfig) {
            Chart {
              ForEach(kinds.indices, id: \.self) { index in
                SectorMark(
                  angle: .value("Size", sizes[index]),
                  innerRadius: .ratio(0.62),
                  outerRadius: .ratio(index == active ? 0.9 : 0.85),
                  angularInset: 1.5
                )
                .cornerRadius(4)
                .foregroundStyle(chartConfig.color(kinds[index]))
              }
            }
            .background {
              VStack(spacing: 0) {
                Text(LocalizedStringKey(active.map { chartConfig.label(kinds[$0]) } ?? "Used"))
                  .textStyle(.sm)
                  .foregroundStyle(EsuissColor.mutedForeground)
                Text(verbatim: gigabytes(value))
                  .textStyle(.xl3, weight: .semibold)
                  .monospacedDigit()
                  .lineLimit(1)
                  .minimumScaleFactor(0.6)
                  .frame(maxWidth: 124)
                  .contentTransition(.numericText(value: value))
                Text(verbatim: "\(Int((value / max(total, 1) * 100).rounded()))%")
                  .textStyle(.sm)
                  .monospacedDigit()
                  .foregroundStyle(EsuissColor.mutedForeground)
              }
              .multilineTextAlignment(.center)
            }
            .chartTooltip(
              angle: sizes,
              labels: kinds.map { chartConfig.label($0) },
              innerRadius: 0.62,
              outerRadius: 0.9,
              selection: activeBinding,
              accessibilityLabel: "\(device.label) storage",
              items: { [ChartTooltipItem(kinds[$0], value: (sizes[$0] * 10).rounded() / 10)] }
            ) { _ in
              EmptyView()
            }
            .padding(5)
          }
          .frame(width: 280, height: 280)
          VStack(spacing: 0) {
            ForEach(kinds.indices, id: \.self) { index in
              if sizes[index] > 0 {
                row(index, value: sizes[index])
                  .transition(.opacity)
              }
            }
          }
        }
      }
    }
  }

  private func storageBar(sizes: [Double], total: Double) -> some View {
    GeometryReader { geometry in
      HStack(spacing: 0) {
        ForEach(kinds.indices.dropLast(), id: \.self) { index in
          let share = max(0, sizes[index]) / max(total, 1)
          Rectangle()
            .fill(chartConfig.color(kinds[index]))
            .opacity(active == nil || active == index ? 1 : 0.35)
            .frame(width: geometry.size.width * share)
            .padding(.trailing, min(2, share * 400))
        }
      }
    }
    .frame(height: 10)
    .background(EsuissColor.secondary)
    .clipShape(Capsule())
    .accessibilityHidden(true)
  }

  private func row(_ index: Int, value: Double) -> some View {
    let kind = kinds[index]
    return Button {
      activeBinding.wrappedValue = active == index ? nil : index
    } label: {
      HStack(spacing: 10) {
        Circle()
          .fill(chartConfig.color(kind))
          .overlay {
            if kind == "free" {
              Circle().strokeBorder(EsuissColor.input, lineWidth: 1)
            }
          }
          .frame(width: 10, height: 10)
        Text(LocalizedStringKey(chartConfig.label(kind)))
          .textStyle(.base)
          .foregroundStyle(EsuissColor.foreground)
          .frame(maxWidth: .infinity, alignment: .leading)
        Text(verbatim: gigabytes(value))
          .textStyle(.base)
          .monospacedDigit()
          .foregroundStyle(EsuissColor.mutedForeground)
      }
      .padding(.horizontal, 8)
      .padding(.vertical, 9)
      .background(active == index ? EsuissColor.secondary : .clear, in: RoundedRectangle(cornerRadius: Radius.lg, style: .continuous))
      .contentShape(Rectangle())
    }
    .buttonStyle(.plain)
    .accessibilityAddTraits(active == index ? .isSelected : [])
  }
}
```

```kt title="kotlin/charts/PieChartInteractive.kt"
package com.esuiss.ui.charts

import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Card
import com.esuiss.ui.components.CardContent
import com.esuiss.ui.components.CardSize
import com.esuiss.ui.components.CardVariant
import com.esuiss.ui.components.ChartConfig
import com.esuiss.ui.components.ChartConfigItem
import com.esuiss.ui.components.ChartContainer
import com.esuiss.ui.components.ChartSector
import com.esuiss.ui.components.PieChart
import com.esuiss.ui.components.ToggleGroup
import com.esuiss.ui.components.ToggleGroupItem
import com.esuiss.ui.components.ToggleSize
import com.esuiss.ui.components.formReducedMotion
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Palette
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography
import java.util.Locale
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt

private class PieInteractiveStorage(val label: String, val total: Double, val used: Map<String, Double>)

private val kinds = listOf("apps", "photos", "system", "backups", "drive", "messages", "mail", "other", "free")

private val storage = linkedMapOf(
    "phone" to PieInteractiveStorage(
        "Phone",
        256.0,
        mapOf("apps" to 62.4, "photos" to 48.1, "system" to 25.8, "messages" to 9.8, "other" to 6.3),
    ),
    "cloud" to PieInteractiveStorage(
        "Cloud",
        200.0,
        mapOf("photos" to 96.2, "backups" to 31.4, "drive" to 18.7, "messages" to 7.9, "mail" to 2.3),
    ),
)

private val chartConfig = ChartConfig(
    "apps" to ChartConfigItem("Apps") { Palette.red500 },
    "photos" to ChartConfigItem("Photos") { chart4 },
    "system" to ChartConfigItem("System") { chart5 },
    "backups" to ChartConfigItem("Backups") { Palette.purple500 },
    "drive" to ChartConfigItem("Drive") { chart1 },
    "messages" to ChartConfigItem("Messages") { Palette.green500 },
    "mail" to ChartConfigItem("Mail") { Palette.cyan500 },
    "other" to ChartConfigItem("Other") { Palette.yellow500 },
    "free" to ChartConfigItem("Free") { secondary },
)

private val MorphEasing = CubicBezierEasing(0.65f, 0f, 0.35f, 1f)

private fun sizes(device: String): List<Double> {
    val entry = storage.getValue(device)
    val taken = entry.used.values.sum()
    return kinds.map { if (it == "free") entry.total - taken else entry.used[it] ?: 0.0 }
}

private fun gigabytes(value: Double) = String.format(Locale.US, "%.1f", max(0.0, value))

@Composable
fun PieChartInteractive(modifier: Modifier = Modifier) {
    val colors = EsuissTheme.colors
    val reduced = formReducedMotion()
    var device by remember { mutableStateOf("phone") }
    var active by remember { mutableStateOf<Int?>(null) }
    val target = sizes(device)
    val values: List<State<Float>> = kinds.indices.map { index ->
        animateFloatAsState(
            target[index].toFloat(),
            if (reduced) snap() else tween(700, easing = MorphEasing),
            label = "size",
        )
    }
    val lifts: List<State<Float>> = kinds.indices.map { index ->
        animateFloatAsState(
            if (active == index) 1f else 0f,
            if (reduced) snap() else tween(260, easing = MorphEasing),
            label = "lift",
        )
    }
    val sizes = values.map { it.value.toDouble() }
    val total = sizes.sum()
    val used = total - sizes.last()
    val value = active?.let { sizes[it] } ?: used
    val swatch = { kind: String -> chartConfig[kind]?.color?.invoke(colors) ?: colors.mutedForeground }
    Card(modifier, variant = CardVariant.Filled, size = CardSize.Sm) {
        CardContent {
            Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.Top) {
                Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
                    BasicText(
                        "${storage.getValue(device).label} storage",
                        style = Typography.lg.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold),
                    )
                    BasicText(
                        "${gigabytes(target.sum() - target.last())} GB of ${storage.getValue(device).total.toInt()} GB used",
                        style = Typography.sm.copy(color = colors.mutedForeground, fontFeatureSettings = "tnum"),
                    )
                }
                ToggleGroup(
                    value = device,
                    onValueChange = { next ->
                        if (next != null) {
                            device = next
                            active = null
                        }
                    },
                    modifier = Modifier.semantics { contentDescription = "Device" },
                    size = ToggleSize.Sm,
                    spacing = 0,
                ) {
                    for ((key, entry) in storage) ToggleGroupItem(entry.label, key)
                }
            }
        }
        CardContent(Modifier.padding(top = 20.dp)) {
            Column(verticalArrangement = Arrangement.spacedBy(20.dp)) {
                StorageBar(sizes, total, active)
                Box(
                    Modifier
                        .align(Alignment.CenterHorizontally)
                        .size(280.dp),
                ) {
                    ChartContainer(chartConfig, Modifier.matchParentSize()) {
                        PieChart(
                            accessibilityLabel = "${storage.getValue(device).label} storage",
                            selectedIndex = active,
                            onSelectedIndexChange = { active = it },
                        ) {
                            Pie(
                                kinds.indices.toList(),
                                value = { max(0.0, sizes[it]) },
                                nameKey = { kinds[it] },
                                innerRadius = 0.64f,
                                outerRadius = 0.88f,
                                startAngle = 90f,
                                endAngle = -270f,
                                stroke = colors.muted,
                                strokeWidth = 3.dp,
                                cornerRadius = 4.dp,
                                sectors = { index -> ChartSector(lift = 7.dp * lifts[index].value) },
                                isAnimationActive = false,
                            )
                        }
                    }
                    Column(
                        Modifier
                            .align(Alignment.Center)
                            .widthIn(max = 160.dp)
                            .semantics { liveRegion = LiveRegionMode.Polite },
                        horizontalAlignment = Alignment.CenterHorizontally,
                    ) {
                        BasicText(
                            active?.let { chartConfig[kinds[it]]?.label } ?: "Used",
                            style = Typography.sm.copy(color = colors.mutedForeground),
                        )
                        BasicText(
                            "${gigabytes(value)} GB",
                            style = Typography.xl3.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold, fontFeatureSettings = "tnum"),
                            maxLines = 1,
                        )
                        BasicText(
                            "${(value / max(total, 1.0) * 100).roundToInt()}%",
                            style = Typography.sm.copy(color = colors.mutedForeground, fontFeatureSettings = "tnum"),
                        )
                    }
                }
                Column {
                    kinds.forEachIndexed { index, kind ->
                        AnimatedVisibility(
                            visible = target[index] > 0,
                            enter = fadeIn(tween(500)) + expandVertically(tween(500, easing = MorphEasing)),
                            exit = fadeOut(tween(500)) + shrinkVertically(tween(500, easing = MorphEasing)),
                        ) {
                            StorageRow(
                                label = chartConfig[kind]?.label ?: kind,
                                value = "${gigabytes(sizes[index])} GB",
                                color = swatch(kind),
                                free = kind == "free",
                                selected = active == index,
                                onClick = { active = if (active == index) null else index },
                            )
                        }
                    }
                }
            }
        }
    }
}

@Composable
private fun StorageBar(sizes: List<Double>, total: Double, active: Int?) {
    val colors = EsuissTheme.colors
    Layout(
        content = {
            kinds.indices.toList().dropLast(1).forEach { index ->
                Box(
                    Modifier
                        .fillMaxHeight()
                        .graphicsLayer { alpha = if (active == null || active == index) 1f else 0.35f }
                        .background(chartConfig[kinds[index]]?.color?.invoke(colors) ?: colors.mutedForeground),
                )
            }
        },
        modifier = Modifier
            .fillMaxWidth()
            .height(10.dp)
            .clip(CircleShape)
            .background(colors.secondary)
            .clearAndSetSemantics {},
    ) { measurables, constraints ->
        val width = constraints.maxWidth
        val height = constraints.maxHeight
        var x = 0f
        val placed = measurables.mapIndexed { index, measurable ->
            val share = max(0.0, sizes[index]) / max(total, 1.0)
            val w = (width * share).toFloat()
            val placeable = measurable.measure(Constraints.fixed(w.roundToInt().coerceAtLeast(0), height))
            val start = x
            x += w + min(2.dp.toPx(), (share * 400).toFloat().dp.toPx())
            placeable to start
        }
        layout(width, height) {
            for ((placeable, start) in placed) placeable.place(start.roundToInt(), 0)
        }
    }
}

@Composable
private fun StorageRow(
    label: String,
    value: String,
    color: Color,
    free: Boolean,
    selected: Boolean,
    onClick: () -> Unit,
) {
    val colors = EsuissTheme.colors
    val shape = RoundedCornerShape(Radius.lg)
    Row(
        Modifier
            .fillMaxWidth()
            .clip(shape)
            .background(if (selected) colors.secondary else Color.Transparent, shape)
            .clickable(
                interactionSource = remember { MutableInteractionSource() },
                indication = null,
                role = Role.Button,
                onClick = onClick,
            )
            .semantics { this.selected = selected }
            .padding(horizontal = 8.dp, vertical = 9.dp),
        horizontalArrangement = Arrangement.spacedBy(10.dp),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Box(
            Modifier
                .size(10.dp)
                .background(color, CircleShape)
                .then(if (free) Modifier.border(1.dp, colors.input, CircleShape) else Modifier),
        )
        BasicText(
            label,
            Modifier.weight(1f),
            style = Typography.base.copy(color = colors.foreground),
            maxLines = 1,
            overflow = TextOverflow.Ellipsis,
        )
        BasicText(value, style = Typography.base.copy(color = colors.mutedForeground, fontFeatureSettings = "tnum"))
    }
}
```

A `Card` with a `ToggleGroup` that switches between phone and cloud storage, a stacked usage bar, a `PieChart` donut and a list of categories. Sizes come from the `STORAGE` record, and free space is the total minus what is used. Pressing a slice or a list row selects that category: its slice lifts outward, other segments in the bar dim, and the center text shows its size and share. Pressing it again returns to total used. The donut sets `isAnimationActive={false}` and draws without the enter animation.

## Installation

**React Native**

```bash title="Terminal"
npx shadcn@latest add @esuiss-rn/pie-chart-interactive
```

See [Installation](https://ui.suiss.app/docs/installation) for the `components.json` setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add pie-chart-interactive
```

**SwiftUI**

```bash title="Terminal"
npx @esuiss/ui@latest add pie-chart-interactive --platform swift
```

**Jetpack Compose**

```bash title="Terminal"
npx @esuiss/ui@latest add pie-chart-interactive --platform kotlin
```

The CLI also adds what it builds on: [Card](https://ui.suiss.app/docs/components/card), [Chart](https://ui.suiss.app/docs/components/chart), [Text](https://ui.suiss.app/docs/components/text), [Toggle Group](https://ui.suiss.app/docs/components/toggle-group).

### Manual

**React Native**

**Uniwind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn
```

2. Add the pieces it builds on: [Card](https://ui.suiss.app/docs/components/card), [Chart](https://ui.suiss.app/docs/components/chart), [Text](https://ui.suiss.app/docs/components/text), [Toggle Group](https://ui.suiss.app/docs/components/toggle-group).

3. Copy the source into `components/charts/pie-chart-interactive.tsx`:

- `uniwind/components/charts/pie-chart-interactive.tsx`: shown above

4. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn
```

2. Add the pieces it builds on: [Card](https://ui.suiss.app/docs/components/card), [Chart](https://ui.suiss.app/docs/components/chart), [Text](https://ui.suiss.app/docs/components/text), [Toggle Group](https://ui.suiss.app/docs/components/toggle-group).

3. Copy the source into `components/charts/pie-chart-interactive.tsx`:

```tsx title="nativewind/components/charts/pie-chart-interactive.tsx"
import { cn } from "cn"
import * as React from "react"
import { Pressable, View } from "react-native"
import { Card, CardContent, CardHeader } from "@/components/ui/card"
import {
  type ChartConfig,
  ChartContainer,
  Pie,
  PieChart,
} from "@/components/ui/chart"
import { Text } from "@/components/ui/text"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

type Device = "phone" | "cloud"

type Kind =
  | "apps"
  | "photos"
  | "system"
  | "backups"
  | "drive"
  | "messages"
  | "mail"
  | "other"
  | "free"

const KINDS: Kind[] = [
  "apps",
  "photos",
  "system",
  "backups",
  "drive",
  "messages",
  "mail",
  "other",
  "free",
]

const STORAGE: Record<
  Device,
  { label: string; total: number; used: Partial<Record<Kind, number>> }
> = {
  phone: {
    label: "Phone",
    total: 256,
    used: { apps: 62.4, photos: 48.1, system: 25.8, messages: 9.8, other: 6.3 },
  },
  cloud: {
    label: "Cloud",
    total: 200,
    used: {
      photos: 96.2,
      backups: 31.4,
      drive: 18.7,
      messages: 7.9,
      mail: 2.3,
    },
  },
}

const chartConfig = {
  apps: { label: "Apps", color: "red-500" },
  photos: { label: "Photos", color: "chart-4" },
  system: { label: "System", color: "chart-5" },
  backups: { label: "Backups", color: "purple-500" },
  drive: { label: "Drive", color: "chart-1" },
  messages: { label: "Messages", color: "green-500" },
  mail: { label: "Mail", color: "cyan-500" },
  other: { label: "Other", color: "yellow-500" },
  free: { label: "Free", color: "secondary" },
} satisfies ChartConfig

const SWATCH: Record<Kind, string> = {
  apps: "bg-red-500",
  photos: "bg-orange-500",
  system: "bg-zinc-500",
  backups: "bg-purple-500",
  drive: "bg-blue-500",
  messages: "bg-green-500",
  mail: "bg-cyan-500",
  other: "bg-yellow-500",
  free: "bg-secondary border border-input",
}

const LIFT = 7

function formatSize(value: number) {
  try {
    return new Intl.NumberFormat("en-US", {
      minimumFractionDigits: 1,
      maximumFractionDigits: 1,
    }).format(value)
  } catch {
    return value.toFixed(1)
  }
}

function sizes(device: Device) {
  const { total, used } = STORAGE[device]
  const taken = Object.values(used).reduce((sum, value) => sum + value, 0)
  return KINDS.map((kind) =>
    kind === "free" ? total - taken : (used[kind] ?? 0)
  )
}

export function PieChartInteractive() {
  const [device, setDevice] = React.useState<Device>("phone")
  const [active, setActive] = React.useState<Kind | null>(null)
  const values = React.useMemo(() => sizes(device), [device])
  const sizeOf = (kind: Kind) => values[KINDS.indexOf(kind)] ?? 0
  const total = values.reduce((sum, value) => sum + value, 0)
  const used = total - sizeOf("free")
  const activeValue = active ? sizeOf(active) : used
  const data = KINDS.map((kind, index) => ({
    kind,
    size: Math.max(0, values[index] ?? 0),
  }))

  return (
    <Card variant="filled" size="sm">
      <CardHeader className="flex-row flex-wrap items-start justify-between gap-4">
        <View className="min-w-48 grow gap-0.5">
          <Text className="text-lg font-semibold">
            {`${STORAGE[device].label} storage`}
          </Text>
          <Text className="text-sm text-muted-foreground tabular-nums">
            {`${formatSize(used)} GB of ${STORAGE[device].total} GB used`}
          </Text>
        </View>
        <ToggleGroup
          spacing={0}
          value={[device]}
          onValueChange={(value) => {
            if (!value[0]) return
            setDevice(value[0] as Device)
            setActive(null)
          }}
          accessibilityLabel="Device"
        >
          {(Object.keys(STORAGE) as Device[]).map((key) => (
            <ToggleGroupItem key={key} value={key} size="sm" className="px-5">
              <Text>{STORAGE[key].label}</Text>
            </ToggleGroupItem>
          ))}
        </ToggleGroup>
      </CardHeader>
      <CardContent className="gap-5">
        <View
          accessibilityElementsHidden
          importantForAccessibility="no-hide-descendants"
          className="h-2.5 flex-row overflow-hidden rounded-full bg-secondary"
        >
          {KINDS.filter((kind) => kind !== "free").map((kind) => {
            const share = (Math.max(0, sizeOf(kind)) / total) * 100
            return (
              <View
                key={kind}
                style={{
                  width: `${share}%`,
                  marginEnd: Math.min(2, share * 4),
                }}
                className={cn(
                  "h-full",
                  SWATCH[kind],
                  active && active !== kind && "opacity-35"
                )}
              />
            )
          })}
        </View>
        <View className="flex-row flex-wrap items-center justify-center gap-6">
          <View className="size-[17.5rem] max-w-full">
            <View
              pointerEvents="none"
              accessibilityLiveRegion="polite"
              className="absolute inset-0 items-center justify-center"
            >
              <Text className="text-sm text-muted-foreground">
                {active ? chartConfig[active].label : "Used"}
              </Text>
              <Text className="text-3xl font-semibold tracking-tight tabular-nums">
                {`${formatSize(activeValue)} GB`}
              </Text>
              <Text className="text-sm text-muted-foreground tabular-nums">
                {`${Math.round((activeValue / total) * 100)}%`}
              </Text>
            </View>
            <ChartContainer
              config={chartConfig}
              accessibilityLabel={`${STORAGE[device].label} storage`}
              className="aspect-square size-full"
            >
              <PieChart
                key={device}
                onActiveIndexChange={(index) =>
                  setActive(index === null ? null : (KINDS[index] ?? null))
                }
              >
                <Pie
                  isAnimationActive={false}
                  data={data}
                  dataKey="size"
                  nameKey="kind"
                  innerRadius="64%"
                  outerRadius="88%"
                  startAngle={90}
                  endAngle={-270}
                  stroke="muted"
                  strokeWidth={3}
                  cornerRadius={4}
                  shape={(sector, index) => ({
                    ...sector,
                    outerRadius:
                      sector.outerRadius + (KINDS[index] === active ? LIFT : 0),
                  })}
                />
              </PieChart>
            </ChartContainer>
          </View>
          <View className="min-w-60 grow basis-60">
            {KINDS.filter((kind) => sizeOf(kind) > 0).map((kind) => (
              <Pressable
                key={kind}
                role="button"
                accessibilityState={{ selected: active === kind }}
                onPress={() =>
                  setActive((current) => (current === kind ? null : kind))
                }
                className={cn(
                  "flex-row items-center gap-2.5 rounded-lg px-2 py-[0.5625rem]",
                  active === kind && "bg-secondary"
                )}
              >
                <View
                  className={cn("size-2.5 shrink-0 rounded-full", SWATCH[kind])}
                />
                <Text className="grow text-base">
                  {chartConfig[kind].label}
                </Text>
                <Text className="text-base text-muted-foreground tabular-nums">
                  {`${formatSize(Math.max(0, sizeOf(kind)))} GB`}
                </Text>
              </Pressable>
            ))}
          </View>
        </View>
      </CardContent>
    </Card>
  )
}
```

4. Update the import paths to match your project.

**StyleSheet**

1. Add the pieces it builds on: [Card](https://ui.suiss.app/docs/components/card), [Chart](https://ui.suiss.app/docs/components/chart), [Text](https://ui.suiss.app/docs/components/text), [Toggle Group](https://ui.suiss.app/docs/components/toggle-group).

2. Copy the source into `components/charts/pie-chart-interactive.tsx`:

```tsx title="stylesheet/components/charts/pie-chart-interactive.tsx"
import * as React from "react"
import { Pressable, StyleSheet, View } from "react-native"
import { Card, CardContent, CardHeader } from "@/components/ui/card"
import {
  type ChartConfig,
  ChartContainer,
  Pie,
  PieChart,
} from "@/components/ui/chart"
import { Text } from "@/components/ui/text"
import {
  fontSize,
  palette,
  type ThemeColors,
  useTheme,
} from "@/components/ui/theme"

import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

type Tone = keyof ThemeColors | keyof typeof palette

function tone(colors: ThemeColors, key: Tone) {
  return { ...colors, ...palette }[key]
}

type Device = "phone" | "cloud"

type Kind =
  | "apps"
  | "photos"
  | "system"
  | "backups"
  | "drive"
  | "messages"
  | "mail"
  | "other"
  | "free"

const KINDS: Kind[] = [
  "apps",
  "photos",
  "system",
  "backups",
  "drive",
  "messages",
  "mail",
  "other",
  "free",
]

const STORAGE: Record<
  Device,
  { label: string; total: number; used: Partial<Record<Kind, number>> }
> = {
  phone: {
    label: "Phone",
    total: 256,
    used: { apps: 62.4, photos: 48.1, system: 25.8, messages: 9.8, other: 6.3 },
  },
  cloud: {
    label: "Cloud",
    total: 200,
    used: {
      photos: 96.2,
      backups: 31.4,
      drive: 18.7,
      messages: 7.9,
      mail: 2.3,
    },
  },
}

const chartConfig = {
  apps: { label: "Apps", color: "red-500" },
  photos: { label: "Photos", color: "chart-4" },
  system: { label: "System", color: "chart-5" },
  backups: { label: "Backups", color: "purple-500" },
  drive: { label: "Drive", color: "chart-1" },
  messages: { label: "Messages", color: "green-500" },
  mail: { label: "Mail", color: "cyan-500" },
  other: { label: "Other", color: "yellow-500" },
  free: { label: "Free", color: "secondary" },
} satisfies ChartConfig

const SWATCH: Record<Kind, Tone> = {
  apps: "red500",
  photos: "chart4",
  system: "chart5",
  backups: "purple500",
  drive: "chart1",
  messages: "green500",
  mail: "cyan500",
  other: "yellow500",
  free: "secondary",
}

const styles = StyleSheet.create({
  header: {
    flexDirection: "row",
    flexWrap: "wrap",
    alignItems: "flex-start",
    justifyContent: "space-between",
    gap: 16,
  },
  heading: { minWidth: 192, flexGrow: 1, gap: 2 },
  title: { ...fontSize.lg, fontWeight: "600" },
  caption: { ...fontSize.sm, fontVariant: ["tabular-nums"] },
  device: { paddingHorizontal: 20 },
  content: { gap: 20 },
  bar: {
    height: 10,
    flexDirection: "row",
    overflow: "hidden",
    borderRadius: 9999,
  },
  segment: { height: "100%" },
  dimmed: { opacity: 0.35 },
  body: {
    flexDirection: "row",
    flexWrap: "wrap",
    alignItems: "center",
    justifyContent: "center",
    gap: 24,
  },
  pie: { width: 280, height: 280, maxWidth: "100%" },
  center: {
    ...StyleSheet.absoluteFill,
    alignItems: "center",
    justifyContent: "center",
  },
  value: {
    ...fontSize["3xl"],
    fontWeight: "600",
    letterSpacing: -0.75,
    fontVariant: ["tabular-nums"],
  },
  chart: { width: "100%", height: "100%", aspectRatio: 1 },
  list: { minWidth: 240, flexGrow: 1, flexBasis: 240 },
  row: {
    flexDirection: "row",
    alignItems: "center",
    gap: 10,
    borderRadius: 8,
    paddingHorizontal: 8,
    paddingVertical: 9,
  },
  dot: { width: 10, height: 10, flexShrink: 0, borderRadius: 9999 },
  name: { flexGrow: 1, ...fontSize.base },
  size: { ...fontSize.base, fontVariant: ["tabular-nums"] },
})

const LIFT = 7

function formatSize(value: number) {
  try {
    return new Intl.NumberFormat("en-US", {
      minimumFractionDigits: 1,
      maximumFractionDigits: 1,
    }).format(value)
  } catch {
    return value.toFixed(1)
  }
}

function sizes(device: Device) {
  const { total, used } = STORAGE[device]
  const taken = Object.values(used).reduce((sum, value) => sum + value, 0)
  return KINDS.map((kind) =>
    kind === "free" ? total - taken : (used[kind] ?? 0)
  )
}

export function PieChartInteractive() {
  const { colors } = useTheme()
  const [device, setDevice] = React.useState<Device>("phone")
  const [active, setActive] = React.useState<Kind | null>(null)
  const values = React.useMemo(() => sizes(device), [device])
  const sizeOf = (kind: Kind) => values[KINDS.indexOf(kind)] ?? 0
  const total = values.reduce((sum, value) => sum + value, 0)
  const used = total - sizeOf("free")
  const activeValue = active ? sizeOf(active) : used
  const data = KINDS.map((kind, index) => ({
    kind,
    size: Math.max(0, values[index] ?? 0),
  }))

  return (
    <Card variant="filled" size="sm">
      <CardHeader style={styles.header}>
        <View style={styles.heading}>
          <Text style={styles.title}>{`${STORAGE[device].label} storage`}</Text>
          <Text style={[styles.caption, { color: colors.mutedForeground }]}>
            {`${formatSize(used)} GB of ${STORAGE[device].total} GB used`}
          </Text>
        </View>
        <ToggleGroup
          spacing={0}
          value={[device]}
          onValueChange={(value) => {
            if (!value[0]) return
            setDevice(value[0] as Device)
            setActive(null)
          }}
          accessibilityLabel="Device"
        >
          {(Object.keys(STORAGE) as Device[]).map((key) => (
            <ToggleGroupItem
              key={key}
              value={key}
              size="sm"
              style={styles.device}
            >
              <Text>{STORAGE[key].label}</Text>
            </ToggleGroupItem>
          ))}
        </ToggleGroup>
      </CardHeader>
      <CardContent style={styles.content}>
        <View
          accessibilityElementsHidden
          importantForAccessibility="no-hide-descendants"
          style={[styles.bar, { backgroundColor: colors.secondary }]}
        >
          {KINDS.filter((kind) => kind !== "free").map((kind) => {
            const share = (Math.max(0, sizeOf(kind)) / total) * 100
            return (
              <View
                key={kind}
                style={[
                  styles.segment,
                  {
                    width: `${share}%`,
                    marginEnd: Math.min(2, share * 4),
                    backgroundColor: tone(colors, SWATCH[kind]),
                  },
                  active && active !== kind && styles.dimmed,
                ]}
              />
            )
          })}
        </View>
        <View style={styles.body}>
          <View style={styles.pie}>
            <View
              pointerEvents="none"
              accessibilityLiveRegion="polite"
              style={styles.center}
            >
              <Text style={[styles.caption, { color: colors.mutedForeground }]}>
                {active ? chartConfig[active].label : "Used"}
              </Text>
              <Text style={styles.value}>
                {`${formatSize(activeValue)} GB`}
              </Text>
              <Text style={[styles.caption, { color: colors.mutedForeground }]}>
                {`${Math.round((activeValue / total) * 100)}%`}
              </Text>
            </View>
            <ChartContainer
              config={chartConfig}
              accessibilityLabel={`${STORAGE[device].label} storage`}
              style={styles.chart}
            >
              <PieChart
                key={device}
                onActiveIndexChange={(index) =>
                  setActive(index === null ? null : (KINDS[index] ?? null))
                }
              >
                <Pie
                  isAnimationActive={false}
                  data={data}
                  dataKey="size"
                  nameKey="kind"
                  innerRadius="64%"
                  outerRadius="88%"
                  startAngle={90}
                  endAngle={-270}
                  stroke="muted"
                  strokeWidth={3}
                  cornerRadius={4}
                  shape={(sector, index) => ({
                    ...sector,
                    outerRadius:
                      sector.outerRadius + (KINDS[index] === active ? LIFT : 0),
                  })}
                />
              </PieChart>
            </ChartContainer>
          </View>
          <View style={styles.list}>
            {KINDS.filter((kind) => sizeOf(kind) > 0).map((kind) => (
              <Pressable
                key={kind}
                role="button"
                accessibilityState={{ selected: active === kind }}
                onPress={() =>
                  setActive((current) => (current === kind ? null : kind))
                }
                style={[
                  styles.row,
                  active === kind && { backgroundColor: colors.secondary },
                ]}
              >
                <View
                  style={[
                    styles.dot,
                    { backgroundColor: tone(colors, SWATCH[kind]) },
                    kind === "free" && {
                      borderWidth: 1,
                      borderColor: colors.input,
                    },
                  ]}
                />
                <Text style={styles.name}>{chartConfig[kind].label}</Text>
                <Text style={[styles.size, { color: colors.mutedForeground }]}>
                  {`${formatSize(Math.max(0, sizeOf(kind)))} GB`}
                </Text>
              </Pressable>
            ))}
          </View>
        </View>
      </CardContent>
    </Card>
  )
}
```

3. Update the import paths to match your project.

**SwiftUI**

1. Target iOS 17 or later.

2. Add the Swift package to your app:

```swift title="Package.swift"
.package(url: "https://github.com/phosphor-icons/swift", from: "2.1.0")
```

3. Copy the theme files every view uses:

- `swift/Theme/Theme.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Theme.swift)

- `swift/Theme/Tokens.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Tokens.swift)

4. Copy the source and every file it uses:

- `swift/Charts/PieChartInteractive.swift`: shown above

- `swift/Components/Button.swift`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `swift/Components/ButtonGroup.swift`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `swift/Components/Card.swift`: see [Card](https://ui.suiss.app/docs/components/card.md)

- `swift/Components/Chart.swift`: see [Chart](https://ui.suiss.app/docs/components/chart.md)

- `swift/Components/Checkbox.swift`: see [Checkbox](https://ui.suiss.app/docs/components/checkbox.md)

- `swift/Components/Field.swift`: see [Field](https://ui.suiss.app/docs/components/field.md)

- `swift/Components/Haptics.swift`: see [Haptics](https://ui.suiss.app/docs/components/haptics.md)

- `swift/Components/Item.swift`: see [Item](https://ui.suiss.app/docs/components/item.md)

- `swift/Components/Separator.swift`: see [Separator](https://ui.suiss.app/docs/components/separator.md)

- `swift/Components/TextVariant.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Components/TextVariant.swift)

- `swift/Components/Toggle.swift`: see [Toggle](https://ui.suiss.app/docs/components/toggle.md)

- `swift/Components/ToggleGroup.swift`: see [Toggle Group](https://ui.suiss.app/docs/components/toggle-group.md)

5. Add the files to your app target.

**Jetpack Compose**

1. Set `minSdk` to 24 or higher and add the dependencies:

```kotlin title="build.gradle.kts"
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
implementation("com.adamglin:phosphor-icon-android:1.0.0")
```

2. Copy the theme files every view uses:

- `kotlin/theme/Theme.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Theme.kt)

- `kotlin/theme/Tokens.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Tokens.kt)

3. Copy the source and every file it uses:

- `kotlin/charts/PieChartInteractive.kt`: shown above

- `kotlin/components/Card.kt`: see [Card](https://ui.suiss.app/docs/components/card.md)

- `kotlin/components/Chart.kt`: see [Chart](https://ui.suiss.app/docs/components/chart.md)

- `kotlin/components/Checkbox.kt`: see [Checkbox](https://ui.suiss.app/docs/components/checkbox.md)

- `kotlin/components/Content.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/Content.kt)

- `kotlin/components/Field.kt`: see [Field](https://ui.suiss.app/docs/components/field.md)

- `kotlin/components/FormSupport.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/FormSupport.kt)

- `kotlin/components/Haptics.kt`: see [Haptics](https://ui.suiss.app/docs/components/haptics.md)

- `kotlin/components/Icon.kt`: see [Icon](https://ui.suiss.app/docs/components/icon.md)

- `kotlin/components/Label.kt`: see [Label](https://ui.suiss.app/docs/components/label.md)

- `kotlin/components/Separator.kt`: see [Separator](https://ui.suiss.app/docs/components/separator.md)

- `kotlin/components/Toggle.kt`: see [Toggle](https://ui.suiss.app/docs/components/toggle.md)

- `kotlin/components/ToggleGroup.kt`: see [Toggle Group](https://ui.suiss.app/docs/components/toggle-group.md)

4. Update the `com.esuiss.ui` package names to match your project.

## Usage

```tsx
import { PieChartInteractive } from "@/components/charts/pie-chart-interactive"
```

```tsx
<PieChartInteractive />
```

Replace `STORAGE` with your devices, totals and per-category sizes, and keep `KINDS`, `chartConfig` and `SWATCH` in sync when adding categories. The component takes no props.

