# Doughnut Chart

> A customizable doughnut chart component with smooth animations, interactive legends, and flexible styling.

**BNA UI** — a React Native / Expo component library.
These components render through `react-native`, not the DOM: there are no HTML
elements, no Tailwind classes and no Radix primitives. Source is copied into your
project and imported through `@/components/ui/*`, `@/components/charts/*`,
`@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than
hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`)
come from `@/theme/globals`.

- Docs: https://ui.ahmedbna.com/docs/charts/doughnut-chart
- Markdown: https://ui.ahmedbna.com/docs/charts/doughnut-chart.md
- Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/doughnut-chart.json
- Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/doughnut-chart.json
- Install: `npx bna-ui add doughnut-chart`
- npm dependencies: `react-native-reanimated`, `react-native-svg`, `react-native-worklets`
- Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals`, `text`
- Preview recording: https://demo.ahmedbna.com/0351-doughnut-chart-demo.MOV

---

**Example:** A doughnut chart with smooth animations and percentage labels

```tsx
// components/demo/charts/doughnut-chart/doughnut-chart-demo.tsx
import { DoughnutChart } from '@/components/charts/doughnut-chart';
import { ChartContainer } from '@/components/charts/chart-container';
import React from 'react';

const sampleData = [
  { label: 'Sales', value: 120 },
  { label: 'Marketing', value: 98 },
  { label: 'Support', value: 86 },
  { label: 'Development', value: 140 },
  { label: 'Design', value: 75 },
  { label: 'HR', value: 65 },
];

export function DoughnutChartDemo() {
  return (
    <ChartContainer
      title='Department Performance'
      description='Quarterly performance metrics by department'
    >
      <DoughnutChart
        data={sampleData}
        config={{
          height: 300,
          showLabels: true,
          animated: true,
          duration: 1000,
          innerRadius: 0.6,
        }}
      />
    </ChartContainer>
  );
}
```

## Installation

### CLI

```bash
npx bna-ui add doughnut-chart
```

### Manual

**1.** Install the required dependencies.

```bash
npm install react-native-svg react-native-reanimated react-native-worklets
```

**2.** Copy and paste the following code into your project.

```tsx
// components/charts/doughnut-chart.tsx
import { Text } from '@/components/ui/text';
import { useColor } from '@/hooks/useColor';
import React, { useEffect, useState } from 'react';
import { LayoutChangeEvent, View, ViewStyle } from 'react-native';
import Animated, {
  SharedValue,
  useAnimatedProps,
  useSharedValue,
  withTiming,
} from 'react-native-reanimated';
import Svg, { Circle, G, Path, Text as SvgText } from 'react-native-svg';

// Animated SVG Components
const AnimatedPath = Animated.createAnimatedComponent(Path);
const AnimatedCircle = Animated.createAnimatedComponent(Circle);

type AnimatedSliceProps = {
  d: string;
  fill: string;
  animationProgress: SharedValue<number>;
  // A single 100%-share slice makes the arc's start/end points coincide,
  // which SVG's arc command can't draw — render a stroked ring instead.
  fullRing?: {
    cx: number;
    cy: number;
    meanRadius: number;
    strokeWidth: number;
  };
};

// Per-item hook must live in its own mounted subcomponent, not in the
// parent's .map() body — calling useAnimatedProps per loop iteration
// violates Rules of Hooks the moment data.length changes.
const AnimatedSlice = React.memo(
  ({ d, fill, animationProgress, fullRing }: AnimatedSliceProps) => {
    const sliceAnimatedProps = useAnimatedProps(() => ({
      opacity: animationProgress.value,
    }));

    if (fullRing) {
      return (
        <AnimatedCircle
          cx={fullRing.cx}
          cy={fullRing.cy}
          r={fullRing.meanRadius}
          fill='none'
          stroke={fill}
          strokeWidth={fullRing.strokeWidth}
          animatedProps={sliceAnimatedProps}
        />
      );
    }

    return (
      <AnimatedPath d={d} fill={fill} animatedProps={sliceAnimatedProps} />
    );
  }
);

interface ChartConfig {
  width?: number;
  height?: number;
  showLabels?: boolean;
  animated?: boolean;
  duration?: number;
  innerRadius?: number;
}

interface ChartDataPoint {
  label: string;
  value: number;
  color?: string;
}

type Props = {
  data: ChartDataPoint[];
  config?: ChartConfig;
  style?: ViewStyle;
};

export const DoughnutChart = ({ data, config = {}, style }: Props) => {
  const [containerWidth, setContainerWidth] = useState(300);

  const {
    height = 200,
    showLabels = true,
    animated = true,
    duration = 1000,
    innerRadius = 0.5, // Default inner radius as ratio of outer radius
  } = config;

  const chartWidth = containerWidth || config.width || 300;

  const primaryColor = useColor('primary');

  const animationProgress = useSharedValue(0);

  const handleLayout = (event: LayoutChangeEvent) => {
    const { width: measuredWidth } = event.nativeEvent.layout;
    if (measuredWidth > 0) {
      setContainerWidth(measuredWidth);
    }
  };

  useEffect(() => {
    if (animated) {
      animationProgress.value = withTiming(1, { duration });
    } else {
      animationProgress.value = 1;
    }
  }, [data, animated, duration]);

  if (!data.length) return null;

  const total = data.reduce((sum, item) => sum + item.value, 0);
  if (total === 0) return null;

  const outerRadius = Math.min(chartWidth, height) / 2 - 20;
  const clampedInnerRadius = Math.max(0, Math.min(0.95, innerRadius));
  const innerRadiusValue = outerRadius * clampedInnerRadius;
  const centerX = chartWidth / 2;
  const centerY = height / 2;

  let currentAngle = -Math.PI / 2;

  const colors = [
    primaryColor,
    useColor('blue'),
    useColor('green'),
    useColor('orange'),
    useColor('purple'),
    useColor('pink'),
  ];

  return (
    <View
      style={[{ width: '100%' }, style]}
      onLayout={handleLayout}
      accessibilityRole='image'
      accessibilityLabel={`Doughnut chart with ${data.length} slices, total ${Math.round(total)}`}
    >
      <Svg width={chartWidth} height={height}>
        {data.map((item, index) => {
          const sliceAngle = (item.value / total) * 2 * Math.PI;
          const startAngle = currentAngle;
          const endAngle = currentAngle + sliceAngle;

          const largeArcFlag = sliceAngle > Math.PI ? 1 : 0;

          // Outer arc points
          const x1 = centerX + outerRadius * Math.cos(startAngle);
          const y1 = centerY + outerRadius * Math.sin(startAngle);
          const x2 = centerX + outerRadius * Math.cos(endAngle);
          const y2 = centerY + outerRadius * Math.sin(endAngle);

          // Inner arc points
          const x3 = centerX + innerRadiusValue * Math.cos(endAngle);
          const y3 = centerY + innerRadiusValue * Math.sin(endAngle);
          const x4 = centerX + innerRadiusValue * Math.cos(startAngle);
          const y4 = centerY + innerRadiusValue * Math.sin(startAngle);

          const pathData = [
            `M ${x1} ${y1}`,
            `A ${outerRadius} ${outerRadius} 0 ${largeArcFlag} 1 ${x2} ${y2}`,
            `L ${x3} ${y3}`,
            `A ${innerRadiusValue} ${innerRadiusValue} 0 ${largeArcFlag} 0 ${x4} ${y4}`,
            'Z',
          ].join(' ');

          // Label position
          const labelAngle = startAngle + sliceAngle / 2;
          const labelRadius = (outerRadius + innerRadiusValue) / 2;
          const labelX = centerX + labelRadius * Math.cos(labelAngle);
          const labelY = centerY + labelRadius * Math.sin(labelAngle);

          currentAngle = endAngle;

          // A single slice spanning the full circle (only one item, or every
          // other item has a value of 0) has coincident arc start/end points.
          const isFullCircle = sliceAngle >= 2 * Math.PI - 1e-6;

          return (
            <G key={`slice-${index}`}>
              <AnimatedSlice
                d={pathData}
                fill={item.color || colors[index % colors.length]}
                animationProgress={animationProgress}
                fullRing={
                  isFullCircle
                    ? {
                        cx: centerX,
                        cy: centerY,
                        meanRadius: (outerRadius + innerRadiusValue) / 2,
                        strokeWidth: outerRadius - innerRadiusValue,
                      }
                    : undefined
                }
              />

              {showLabels && (
                <SvgText
                  x={labelX}
                  y={labelY}
                  textAnchor='middle'
                  fontSize={12}
                  fill='#FFFFFF'
                  fontWeight='600'
                >
                  {Math.round((item.value / total) * 100)}%
                </SvgText>
              )}
            </G>
          );
        })}
      </Svg>

      {/* Legend */}
      <View style={{ marginTop: 10 }}>
        {data.map((item, index) => (
          <View
            key={`legend-${index}`}
            style={{
              flexDirection: 'row',
              alignItems: 'center',
              marginBottom: 5,
            }}
          >
            <View
              style={{
                width: 12,
                height: 12,
                borderRadius: 6,
                backgroundColor: item.color || colors[index % colors.length],
                marginRight: 8,
              }}
            />
            <Text variant='caption'>
              {item.label}: {item.value}
            </Text>
          </View>
        ))}
      </View>
    </View>
  );
};
```

**3.** Update the import paths to match your project setup.

## Usage

```tsx
import { DoughnutChart } from '@/components/charts/doughnut-chart';
```

```tsx
const data = [
  { label: 'Sales', value: 120 },
  { label: 'Marketing', value: 98 },
  { label: 'Support', value: 86 },
  { label: 'Development', value: 140 },
];

<DoughnutChart
  data={data}
  config={{
    height: 300,
    showLabels: true,
    animated: true,
    innerRadius: 0.6,
  }}
/>;
```

## Examples

#### Basic Doughnut Chart

**Example:** A doughnut chart with smooth animations and percentage labels

```tsx
// components/demo/charts/doughnut-chart/doughnut-chart-demo.tsx
import { DoughnutChart } from '@/components/charts/doughnut-chart';
import { ChartContainer } from '@/components/charts/chart-container';
import React from 'react';

const sampleData = [
  { label: 'Sales', value: 120 },
  { label: 'Marketing', value: 98 },
  { label: 'Support', value: 86 },
  { label: 'Development', value: 140 },
  { label: 'Design', value: 75 },
  { label: 'HR', value: 65 },
];

export function DoughnutChartDemo() {
  return (
    <ChartContainer
      title='Department Performance'
      description='Quarterly performance metrics by department'
    >
      <DoughnutChart
        data={sampleData}
        config={{
          height: 300,
          showLabels: true,
          animated: true,
          duration: 1000,
          innerRadius: 0.6,
        }}
      />
    </ChartContainer>
  );
}
```

#### Sample Doughnut Chart

**Example:** A sample doughnut chart with custom theme colors

```tsx
// components/demo/charts/doughnut-chart/doughnut-chart-sample.tsx
import { DoughnutChart } from '@/components/charts/doughnut-chart';
import { ChartContainer } from '@/components/charts/chart-container';
import { useColor } from '@/hooks/useColor';
import React from 'react';

const sampleData = [
  { label: 'Revenue', value: 45000 },
  { label: 'Expenses', value: 32000 },
  { label: 'Profit', value: 13000 },
];

export function DoughnutChartSample() {
  const primaryColor = useColor('primary');
  const greenColor = useColor('green');
  const orangeColor = useColor('orange');

  const dataWithColors = [
    { ...sampleData[0], color: primaryColor },
    { ...sampleData[1], color: orangeColor },
    { ...sampleData[2], color: greenColor },
  ];

  return (
    <ChartContainer
      title='Financial Overview'
      description='Q4 2024 financial breakdown'
    >
      <DoughnutChart
        data={dataWithColors}
        config={{
          height: 250,
          showLabels: true,
          animated: true,
          duration: 1500,
          innerRadius: 0.5,
        }}
      />
    </ChartContainer>
  );
}
```

#### Styled Doughnut Chart

**Example:** A customized doughnut chart with custom colors and styling

```tsx
// components/demo/charts/doughnut-chart/doughnut-chart-styled.tsx
import { DoughnutChart } from '@/components/charts/doughnut-chart';
import { ChartContainer } from '@/components/charts/chart-container';
import React from 'react';

const customData = [
  { label: 'Mobile', value: 65, color: '#FF6B6B' },
  { label: 'Desktop', value: 25, color: '#4ECDC4' },
  { label: 'Tablet', value: 8, color: '#45B7D1' },
  { label: 'Other', value: 2, color: '#96CEB4' },
];

export function DoughnutChartStyled() {
  return (
    <ChartContainer
      title='Device Usage'
      description='Traffic distribution by device type'
    >
      <DoughnutChart
        data={customData}
        config={{
          height: 280,
          showLabels: true,
          animated: true,
          duration: 800,
          innerRadius: 0.7,
        }}
        style={{
          backgroundColor: '#f8f9fa',
          borderRadius: 12,
          padding: 16,
        }}
      />
    </ChartContainer>
  );
}
```

#### Large Doughnut Chart

**Example:** A doughnut chart with large dataset and legend-only labels

```tsx
// components/demo/charts/doughnut-chart/doughnut-chart-large.tsx
import { DoughnutChart } from '@/components/charts/doughnut-chart';
import { ChartContainer } from '@/components/charts/chart-container';
import React from 'react';

const largeDataset = [
  { label: 'E-commerce', value: 285, color: '#FF6B6B' },
  { label: 'Social Media', value: 245, color: '#4ECDC4' },
  { label: 'Search Engine', value: 198, color: '#45B7D1' },
  { label: 'Email Marketing', value: 156, color: '#96CEB4' },
  { label: 'Direct Traffic', value: 134, color: '#FFEAA7' },
  { label: 'Referral', value: 89, color: '#DDA0DD' },
  { label: 'Display Ads', value: 67, color: '#98D8C8' },
  { label: 'Video Ads', value: 45, color: '#F7DC6F' },
  { label: 'Affiliate', value: 23, color: '#BB8FCE' },
  { label: 'Other', value: 18, color: '#AED6F1' },
];

export function DoughnutChartLarge() {
  return (
    <ChartContainer
      title='Traffic Sources'
      description='Website traffic breakdown by source (last 30 days)'
    >
      <DoughnutChart
        data={largeDataset}
        config={{
          height: 320,
          showLabels: false, // Disable labels for large datasets
          animated: true,
          duration: 1200,
          innerRadius: 0.4,
        }}
      />
    </ChartContainer>
  );
}
```

## API Reference

### DoughnutChart

A customizable doughnut chart component with smooth animations, interactive legends, and flexible styling. Perfect for displaying proportional data with emphasis on part-to-whole relationships.

| Prop     | Type               | Default | Description                                   |
| -------- | ------------------ | ------- | --------------------------------------------- |
| `data`   | `ChartDataPoint[]` | -       | Array of data points to display on the chart. |
| `config` | `ChartConfig`      | `{}`    | Configuration object for chart appearance.    |
| `style`  | `ViewStyle`        | -       | Additional styles to apply to the chart.      |

### ChartDataPoint

| Prop    | Type     | Description                          |
| ------- | -------- | ------------------------------------ |
| `label` | `string` | The label for the data point.        |
| `value` | `number` | The value for the data point.        |
| `color` | `string` | Optional custom color for the slice. |

### ChartConfig

| Prop          | Type      | Default | Description                                       |
| ------------- | --------- | ------- | ------------------------------------------------- |
| `width`       | `number`  | -       | Fixed width of the chart (auto-sizes if omitted). |
| `height`      | `number`  | `200`   | Height of the chart.                              |
| `showLabels`  | `boolean` | `true`  | Whether to show percentage labels on slices.      |
| `animated`    | `boolean` | `true`  | Whether to animate the chart on load.             |
| `duration`    | `number`  | `1000`  | Animation duration in milliseconds.               |
| `innerRadius` | `number`  | `0.5`   | Inner radius as a ratio of outer radius (0-1).    |

## Features

- **Circular Layout**: Displays data as slices of a circle for intuitive proportion visualization
- **Smooth Animations**: Built-in animations using React Native Reanimated
- **Responsive Design**: Automatically adapts to container width
- **Interactive Legend**: Built-in legend with color indicators and values
- **Custom Colors**: Support for individual slice colors
- **Percentage Labels**: Shows percentage values on chart slices
- **Theme Integration**: Uses theme colors for consistent styling
- **Configurable Inner Radius**: Adjustable doughnut thickness

## Use Cases

Doughnut charts are particularly effective for:

- **Part-to-Whole Relationships**: Showing how individual parts contribute to a total
- **Market Share Analysis**: Displaying market distribution across competitors
- **Budget Breakdown**: Visualizing spending allocation across categories
- **Survey Results**: Showing response distributions with clear proportions
- **Resource Allocation**: Displaying time, money, or resource distribution
- **Performance Metrics**: Showing completion rates or achievement percentages

## Design Considerations

The circular layout of the DoughnutChart makes it ideal for:

- **Proportional Data**: Perfect for showing percentages and ratios
- **Limited Categories**: Works best with 3-8 categories for clarity
- **Space Efficiency**: Compact design that fits well in dashboards
- **Visual Impact**: Immediately conveys relative sizes and proportions

## Accessibility

The DoughnutChart component includes several accessibility features:

- Semantic SVG structure for screen readers
- Proper contrast ratios for visual elements
- Text labels with percentage values
- Interactive legend for detailed information
- Color-blind friendly default palette
- Keyboard navigation support

## Performance

The component is optimized for performance:

- Uses React Native Reanimated for smooth 60fps animations
- Efficient SVG rendering with minimal re-renders
- Automatic cleanup of animation values
- Responsive layout calculations
- Optimized path calculations for smooth arcs

## Styling

The component integrates with your theme system:

- Uses theme colors (`primary`, `blue`, `green`, etc.) for default slice colors
- Uses `mutedForeground` color for labels and legend text
- Supports custom colors per data point
- Automatic color cycling for consistent appearance
- Customizable container styling

## Animation

The chart features smooth entry animations:

- Slices animate with fade-in effect
- Configurable animation duration
- Can be disabled for instant rendering
- Uses React Native Reanimated for optimal performance
- Smooth transitions maintain visual continuity

## Legend

The built-in legend provides:

- Color-coded indicators for each slice
- Category labels with actual values
- Automatic layout below the chart
- Consistent styling with theme colors
- Compact design that doesn't overwhelm the chart
