# Donut Chart

> A ring of rounded slices that sweeps in; tap a slice to bring it forward and read its share in the middle.

- Category: Data
- Version: 1.0.0
- Platforms: iOS, Android, web
- Dependencies: `@shopify/react-native-skia`, `react-native-gesture-handler`, `react-native-reanimated`, `react-native-worklets`
- Requires: React Native New Architecture (Reanimated 4)

## Install

```bash
npx kinetik-ui add donut-chart
```

With the shadcn CLI:

```bash
npx shadcn@latest add https://kinetik-ui.dev/r/donut-chart.json
```

## Usage

```tsx
import { DonutChart } from '@/components/kinetik/donut-chart';

<DonutChart
  accessibilityLabel="Spending by category"
  data={[
    { label: 'Rent', value: 1240 },
    { label: 'Groceries', value: 420 },
    { label: 'Transport', value: 160 },
  ]}
  formatValue={(v) => `$${v}`}
/>;
```

Slices default to a monochrome ramp of the theme's strongest ink; give a slice a `color` to pick your own. The legend under the ring lists every slice with its share and value.

## When to use

- Parts of a whole, with a few slices: budgets, storage, time spent.
- Summaries where the total matters as much as the split.

## When not to use

- More than about six slices; small ones become hard to see and to tap.
- Comparing values that do not add up to a whole. Use Bar Chart.

## Notes

- Tap a slice or its legend row to select it; tap it again, or the middle of the ring, to clear.
- Gaps stay even because the rounded caps are taken out of each slice's sweep.
- The ring sweeps in whenever the data changes.

## DonutChartProps

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `data` | `readonly DonutSlice[]` |  |  |
| `accessibilityLabel` | `string` |  | A summary of what the chart shows, e.g. "Spending by category". |
| `size`? | `number` | `200` | Diameter of the ring. Defaults to 200. |
| `thickness`? | `number` | `18` | Ring thickness. Defaults to 18. |
| `gap`? | `number` | `6` | Gap between slices, in degrees. Defaults to 6. |
| `centerLabel`? | `string` | `'Total'` | Shown in the middle under the total. Defaults to 'Total'. |
| `formatValue`? | `(value: number) => string` | `(v) => String(v)` |  |
| `onSelect`? | `(index: number \| null) => void` |  | Called with the selected slice, or null when the selection clears. |
| `legend`? | `boolean` | `true` | A list of slices under the ring. Defaults to true. |
| `haptics`? | `boolean` | `true` | A tick when a slice is selected. Defaults to true. |
| `style`? | `StyleProp<ViewStyle>` |  |  |

## Accessibility

- Reduced motion: The ring appears complete and selection changes without the bounce.
- Screen readers: The ring reads as an image with every slice and value; legend rows are buttons with a selected state.
- Touch target: Legend rows are 44 pt tall and full width; the ring itself is also tappable.

## Performance

Slice paths are built once per data change; the sweep and the selection are shared values read by one Skia canvas.

- Real-device measurement pending.

## Source

`components/kinetik/donut-chart.tsx`

```tsx
import { Canvas, Path, Skia, type SkPath } from '@shopify/react-native-skia';
import { useEffect, useMemo, useState } from 'react';
import { Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
  Easing,
  useAnimatedStyle,
  useDerivedValue,
  useSharedValue,
  withSpring,
  withTiming,
  type SharedValue,
} from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';

import { useHaptic } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { spring, timing } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { minTouchTarget, withAlpha } from '../../lib/kinetik/tokens/tokens';

export type DonutSlice = { label: string; value: number; color?: string };

export type DonutChartProps = {
  data: readonly DonutSlice[];
  /** Diameter of the ring. Defaults to 200. */
  size?: number;
  /** Ring thickness. Defaults to 18. */
  thickness?: number;
  /** Gap between slices, in degrees. Defaults to 6. */
  gap?: number;
  /** Shown in the middle under the total. Defaults to 'Total'. */
  centerLabel?: string;
  formatValue?: (value: number) => string;
  /** Called with the selected slice, or null when the selection clears. */
  onSelect?: (index: number | null) => void;
  /** A list of slices under the ring. Defaults to true. */
  legend?: boolean;
  /** A tick when a slice is selected. Defaults to true. */
  haptics?: boolean;
  /** A summary of what the chart shows, e.g. "Spending by category". */
  accessibilityLabel: string;
  style?: StyleProp<ViewStyle>;
};

/** Extra width a selected slice grows by. */
const LIFT = 7;

/**
 * A ring of rounded slices that sweeps in around the clock. Tap a slice, or
 * its row in the legend, to bring it forward: it thickens, the others step
 * back, and the centre shows its share.
 */
export function DonutChart({
  data,
  size = 200,
  thickness = 18,
  gap = 6,
  centerLabel = 'Total',
  formatValue = (v) => String(v),
  onSelect,
  legend = true,
  haptics = true,
  accessibilityLabel,
  style,
}: DonutChartProps) {
  const { colors, font, space, radius } = useKinetikTheme();
  const reduced = useReduceMotion();
  const haptic = useHaptic(haptics);
  const [selected, setSelected] = useState<number | null>(null);

  const total = data.reduce((s, d) => s + Math.max(0, d.value), 0);
  const ringR = size / 2 - thickness / 2 - LIFT / 2 - 1;
  // A monochrome ramp by default: the strongest ink, stepping back.
  const ramp = [1, 0.62, 0.38, 0.22, 0.12];
  const tint = (d: DonutSlice, i: number) =>
    d.color ?? withAlpha(colors.ember, ramp[i % ramp.length]!);

  // Keyed on the values, not the array: a new array with the same slices must not sweep again.
  const signature = data.map((d) => `${d.label}:${d.value}`).join('|');
  const slices = useMemo(() => {
    // Round caps reach past the arc's ends; take them out of the sweep so the gaps stay even.
    const cap = (thickness / 2 / ringR) * (180 / Math.PI);
    const shares = data.map((d) => (total > 0 ? Math.max(0, d.value) / total : 0));
    return shares.map((share, i) => {
      const from = shares.slice(0, i).reduce((sum, s) => sum + s, 0);
      const at = from + share;
      const sweep = Math.max(0.5, share * 360 - gap - cap * 2);
      const path = Skia.PathBuilder.Make()
        .addArc(
          { x: size / 2 - ringR, y: size / 2 - ringR, width: ringR * 2, height: ringR * 2 },
          -90 + from * 360 + gap / 2 + cap,
          sweep,
        )
        .detach();
      return { path, from, to: at, share };
    });
    // `signature` stands in for `data`.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [signature, total, size, ringR, thickness, gap]);

  const progress = useSharedValue(reduced ? 1 : 0);
  useEffect(() => {
    if (reduced) {
      progress.set(1);
      return;
    }
    progress.set(0);
    progress.set(withTiming(1, { duration: 1100, easing: Easing.bezier(0.2, 0, 0, 1) }));
  }, [slices, reduced, progress]);

  const choose = (i: number | null) => {
    const next = i === selected ? null : i;
    setSelected(next);
    if (next !== null) haptic('tick');
    onSelect?.(next);
  };

  // Tap the ring to pick the slice under the finger; tap the middle to clear.
  const tap = Gesture.Tap()
    .withTestId('donut-chart')
    .onEnd((e, ok) => {
      if (!ok) return;
      const dx = e.x - size / 2;
      const dy = e.y - size / 2;
      const dist = Math.sqrt(dx * dx + dy * dy);
      if (dist < ringR - thickness) {
        scheduleOnRN(choose, null);
        return;
      }
      if (dist > ringR + thickness) return;
      const turn = (((Math.atan2(dy, dx) * 180) / Math.PI + 90 + 360) % 360) / 360;
      const i = slices.findIndex((s) => turn >= s.from && turn < s.to);
      if (i >= 0) scheduleOnRN(choose, i);
    });

  const focus = selected === null ? null : data[selected];
  const centerValue = focus ? formatValue(focus.value) : formatValue(total);
  const centerCaption = focus
    ? `${focus.label} · ${Math.round((Math.max(0, focus.value) / (total || 1)) * 100)}%`
    : centerLabel;

  return (
    <View style={[{ gap: space[5] }, style]}>
      <GestureDetector gesture={tap}>
        <View
          collapsable={false}
          accessible
          accessibilityRole="image"
          accessibilityLabel={`${accessibilityLabel}. ${data
            .map((d) => `${d.label} ${formatValue(d.value)}`)
            .join(', ')}`}
          style={[styles.ring, { width: size, height: size }]}
        >
          <View pointerEvents="none" style={StyleSheet.absoluteFill}>
            <Canvas style={StyleSheet.absoluteFill}>
              {slices.map((s, i) => (
                <Slice
                  key={i}
                  path={s.path}
                  from={s.from}
                  to={s.to}
                  color={tint(data[i]!, i)}
                  thickness={thickness}
                  progress={progress}
                  state={selected === null ? 'rest' : selected === i ? 'focus' : 'back'}
                  reduced={reduced}
                />
              ))}
            </Canvas>
          </View>
          <View pointerEvents="none" style={styles.center}>
            <Center
              value={centerValue}
              caption={centerCaption}
              big={Math.round(size * 0.15)}
              reduced={reduced}
            />
          </View>
        </View>
      </GestureDetector>

      {legend ? (
        <View style={{ gap: 2 }}>
          {data.map((d, i) => {
            const on = selected === i;
            return (
              <Pressable
                key={`${d.label}-${i}`}
                accessibilityRole="button"
                accessibilityLabel={`${d.label}, ${formatValue(d.value)}`}
                accessibilityState={{ selected: on }}
                onPress={() => choose(i)}
                style={[
                  styles.row,
                  {
                    borderRadius: radius.md,
                    backgroundColor: on ? colors.surfaceRaised : 'transparent',
                    paddingHorizontal: space[3],
                    opacity: selected === null || on ? 1 : 0.5,
                  },
                ]}
              >
                <View style={[styles.dot, { backgroundColor: tint(d, i) }]} />
                <Text style={{ flex: 1, color: colors.text, fontSize: font.size.sm }}>
                  {d.label}
                </Text>
                <Text
                  style={{
                    color: colors.textSubtle,
                    fontSize: font.size.sm,
                    fontVariant: ['tabular-nums'],
                    marginRight: space[3],
                  }}
                >
                  {Math.round((Math.max(0, d.value) / (total || 1)) * 100)}%
                </Text>
                <Text
                  style={{
                    color: colors.text,
                    fontSize: font.size.sm,
                    fontWeight: font.weight.semibold,
                    fontVariant: ['tabular-nums'],
                  }}
                >
                  {formatValue(d.value)}
                </Text>
              </Pressable>
            );
          })}
        </View>
      ) : null}
    </View>
  );
}

function Slice({
  path,
  from,
  to,
  color,
  thickness,
  progress,
  state,
  reduced,
}: {
  path: SkPath;
  from: number;
  to: number;
  color: string;
  thickness: number;
  progress: SharedValue<number>;
  state: 'rest' | 'focus' | 'back';
  reduced: boolean;
}) {
  const width = useSharedValue(thickness);
  const fade = useSharedValue(1);
  useEffect(() => {
    width.set(
      withSpring(state === 'focus' ? thickness + LIFT : thickness, spring('bouncy', reduced)),
    );
    fade.set(withTiming(state === 'back' ? 0.3 : 1, timing('base', reduced)));
  }, [state, thickness, reduced, width, fade]);

  // Each slice draws during its own share of the sweep.
  const end = useDerivedValue(() =>
    Math.max(0, Math.min(1, (progress.get() - from) / Math.max(0.0001, to - from))),
  );

  return (
    <Path
      path={path}
      style="stroke"
      strokeWidth={width}
      strokeCap="round"
      color={color}
      opacity={fade}
      end={end}
    />
  );
}

/** The middle: the figure and its caption, rising in whenever they change. */
function Center({
  value,
  caption,
  big,
  reduced,
}: {
  value: string;
  caption: string;
  big: number;
  reduced: boolean;
}) {
  const { colors, font } = useKinetikTheme();
  const t = useSharedValue(1);
  useEffect(() => {
    t.set(0);
    t.set(withTiming(1, timing('base', reduced)));
  }, [value, caption, reduced, t]);
  const style = useAnimatedStyle(() => ({
    opacity: t.get(),
    transform: [{ translateY: (1 - t.get()) * 6 }],
  }));
  return (
    <Animated.View style={[styles.centerInner, style]}>
      <Text
        style={{
          color: colors.text,
          fontSize: big,
          fontWeight: font.weight.bold,
          letterSpacing: -0.8,
          fontVariant: ['tabular-nums'],
        }}
      >
        {value}
      </Text>
      <Text
        numberOfLines={1}
        style={{ color: colors.textMuted, fontSize: font.size.xs, marginTop: 2 }}
      >
        {caption}
      </Text>
    </Animated.View>
  );
}

const styles = StyleSheet.create({
  ring: { alignSelf: 'center' },
  center: { ...StyleSheet.absoluteFill, alignItems: 'center', justifyContent: 'center' },
  centerInner: { alignItems: 'center', maxWidth: '62%' },
  row: { flexDirection: 'row', alignItems: 'center', gap: 10, height: minTouchTarget },
  dot: { width: 10, height: 10, borderRadius: 5 },
});
```
