How To Make Donut PieChart with React and SVG?

main

Here I render the PieChart. The component takes only one prop - a list of items, where an item is an object with a numerical value and a color.

chart

The pie chart container is a regular div element that takes the full size of the parent. To access it's bounding box, we'll wrap it with BoundingBoxAware component. You can learn more about it here.

import { useMemo } from "react"
import { useTheme } from "styled-components"
import { BoundingBoxAware } from "ui/BoundingBoxAware"
import { HSLA } from "ui/colors/HSLA"
import { VStack } from "ui/Stack"
import { sum } from "utils/generic"

import { SvgArc } from "./SvgArc"
import { SvgDisk } from "./SvgDisk"

export interface PieChartItem {
  value: number
  color: HSLA
}

interface Props {
  items: PieChartItem[]
}

const cutoutRadiusShare = 0.68
const totalDegrees = 360
const spaceBetweenInDegrees = 0.8

interface PieChartItemWithAngle extends PieChartItem {
  startAngle: number
  endAngle: number
}

const getItemsWithAngles = (items: PieChartItem[]): PieChartItemWithAngle[] => {
  const total = sum(items.map(item => item.value))

  const itemsWithAngles: PieChartItemWithAngle[] = []

  items.forEach((item, index) => {
    const startAngle = index === 0 ? 0 : itemsWithAngles[index - 1].endAngle
    const endAngle = startAngle + (item.value / total) * totalDegrees

    itemsWithAngles.push({
      ...item,
      startAngle,
      endAngle,
    })
  })

  return itemsWithAngles
}

export const PieChart = ({ items }: Props) => {
  const itemsWithAngles = useMemo(() => getItemsWithAngles(items), [items])

  const { colors } = useTheme()

  return (
    <BoundingBoxAware
      render={({ setElement, boundingBox }) => {
        const renderContent = () => {
          if (!boundingBox) return

          const diameter = Math.min(boundingBox.width, boundingBox.height)
          const radius = diameter / 2
          const cutoutRadius = radius * cutoutRadiusShare

          return (
            <svg style={{ width: diameter, height: diameter }}>
              {items.length < 2 ? (
                <SvgDisk
                  color={
                    items.length === 0 ? colors.backgroundGlass : items[0].color
                  }
                  radius={radius}
                  cutoutRadius={cutoutRadius}
                />
              ) : (
                itemsWithAngles.map(
                  ({ color, startAngle, endAngle }, index) => {
                    return (
                      <SvgArc
                        key={index}
                        color={color}
                        radius={radius}
                        cutoutRadius={cutoutRadius}
                        startAngle={startAngle}
                        endAngle={endAngle - spaceBetweenInDegrees}
                      />
                    )
                  }
                )
              )}
            </svg>
          )
        }

        return (
          <VStack fullWidth fullHeight ref={setElement}>
            {boundingBox && renderContent()}
          </VStack>
        )
      }}
    />
  )
}

We'll make a diameter the smallest size of the parent rectangle. The cutout radius is an empty part of the donut. We'll make it 0.68 of radius size.

If there's only one item, we'll render a disk. Otherwise, we'll make an arc for each item.

To render the disk, we'll use a circle SVG element with a stroke width of radius minus cutout radius. I use an HSLA format for colors in my project. If you're curious, check the link in the description.

import { HSLA } from "ui/colors/HSLA"

interface Props {
  color: HSLA
  radius: number
  cutoutRadius: number
}

export const SvgDisk = ({ color, radius, cutoutRadius }: Props) => (
  <circle
    stroke={color.toCssValue()}
    strokeWidth={radius - cutoutRadius}
    fill="transparent"
    r={radius - (radius - cutoutRadius) / 2}
    cx={radius}
    cy={radius}
  />
)

To render arcs, we need to calculate start and end angles for every item. First, we get a sum of every item's value - it would be 100% of the pie chart. To get those angles, we go over every item. If it's the first one, the start angle is zero. Otherwise, it's the last item's end angle. To calculate the end angle, we convert the item's value to the angle, and add it to the start angle.

import { degreesToRadians } from "shared/utils/degreesToRadians"
import { HSLA } from "ui/colors/HSLA"

interface Props {
  color: HSLA
  startAngle: number
  endAngle: number
  radius: number
  cutoutRadius: number
}

const polarToCartesian = (
  radius: number,
  cutoutRadius: number,
  angleInDegrees: number
) => {
  const angleInRadians = degreesToRadians(angleInDegrees - 90)
  return {
    x: radius + cutoutRadius * Math.cos(angleInRadians),
    y: radius + cutoutRadius * Math.sin(angleInRadians),
  }
}

const getArcPath = (
  radius: number,
  cutoutRadius: number,
  startAngle: number,
  endAngle: number
) => {
  const start = polarToCartesian(radius, radius, endAngle)
  const end = polarToCartesian(radius, radius, startAngle)
  const largeArcFlag = endAngle - startAngle <= 180 ? 0 : 1

  const start2 = polarToCartesian(radius, cutoutRadius, endAngle)
  const end2 = polarToCartesian(radius, cutoutRadius, startAngle)

  return [
    "M",
    start.x,
    start.y,
    "A",
    radius,
    radius,
    0,
    largeArcFlag,
    0,
    end.x,
    end.y,
    "L",
    radius,
    radius,
    "Z",

    "M",
    start2.x,
    start2.y,
    "A",
    cutoutRadius,
    cutoutRadius,
    0,
    largeArcFlag,
    0,
    end2.x,
    end2.y,
    "L",
    radius,
    radius,
    "Z",
  ].join(" ")
}

export const SvgArc = ({
  color,
  startAngle,
  endAngle,
  radius,
  cutoutRadius,
}: Props) => {
  const path = getArcPath(radius, cutoutRadius, startAngle, endAngle)

  return <path fillRule="evenodd" fill={color.toCssValue()} d={path} />
}

To add a little bit of distance between pie slices, we can subtract a small value from every end angle before rendering.

To get a trajectory for the path element, we have the getArcPath function. It converts angles to cartesian coordinates and uses them to draw the path. You can copy the code from the blog post in the description below.