Recently I was working on a project to improve our web app’s billing reports with pie charts that visualize customer usage. I wanted an implementation for the job with minimal foreseeable maintenance that kept our third-party dependencies low.

I thought a quick search on Stack Overflow would yield a copy/paste-able solution, but discovered there isn’t an agreed-upon approach.

Of the various approaches I saw, there are these three that caught my attention, and they all happen to use just HTML and CSS. Each approach has its own merits, and you’ll need to find the right approach for your use case.

Here’s a quick summary of what the implementation of the three approaches looks like:

Using CSS conic-gradient function

//HTML
<div id="shape">
</div>
// CSS
#shape {
 width: 300px;
 height: 300px;
 border-radius: 9999px;
 background: conic-gradient(
  LemonChiffon 0%, LemonChiffon 10%,
  LightGreen 10%, LightGreen 30%,
  WhiteSmoke 30%, WhiteSmoke 100%
 );
}

Preview code snippet

Using SVG <circle> element

// HTML
<div id="shape">
 <svg viewBox="0 0 100 100">
  <circle cx="50" cy="50" r="25" fill="transparent" stroke-width="50" stroke-dashoffset="39.2" stroke-dasharray="15.7 141.3" stroke="LemonChiffon"></circle>
  <circle cx="50" cy="50" r="25" fill="transparent" stroke-width="50" stroke-dashoffset="23.5" stroke-dasharray="31.4 125.6" stroke="LightGreen"></circle>
 </svg>
</div>
// CSS
#shape svg {
 width: 300px;
 height: 300px;
 border-radius: 9999px;
 background-color: WhiteSmoke;
}

Preview code snippet

Using SVG <path>

//HTML
<div id="shape">
 <svg viewBox="0 0 100 100">
  <path d="M50,50 L50,0 A50,50,0,0,1,79.5,9.5 Z" fill="LemonChiffon"></path>
  <path d="M50,50 L79.5,9.5 A50,50,0,0,1,94,73.5 Z" fill="LightGreen"></path>
 </svg>
</div>
// CSS
#shape svg {
 width: 300px;
 height: 300px;
 border-radius: 9999px;
 background-color: WhiteSmoke;
}

Preview code snippet

Each approach has its pros and cons, and offers different types of versatility:

  • conic-gradient approach allows you to not deal with SVG at all. Very little maths is needed but at the cost of slightly less browser compatibility compared to the other solutions.
  • The <circle> element solution requires you to understand how all of its parameters work. But once you’ve got those figured out, you can stack and control pie pieces individually. You can also use the same implementation to turn your pie charts into donut charts.
  • The <path> element solution requires you to understand how to calculate paths, so you need to spend some time learning the basics of SVG. But once you’re comfortable with plotting, you aren’t restricted to the shapes you want to draw. This solution allows highest customization to your chart, like giving the pie slices borders or using background images for each slice.

The SVG <circle> element solution came out on top for me and for our use case: we currently don’t need chart animations/interactions and having the same implementation for both pie charts and donut charts is pretty useful.

The important thing to know about the <circle> solution is that it takes advantage of drawing overlapping circles (one for each segment of the pie chart) and that we use the `dash` feature of SVG to draw each segment. Dashes are normally used to define how to represent breaks in lines, but you can also use them in other SVG objects like circles, and by defining the dashes carefully we can have one dash per circle to generate each slice of the pie chart.

Here’s a quick overview of how the <circle> example above works.

  • Start by defining the viewBox to be 0 0 100 100 to simplify the calculation, fits a circle of 50 units radius. You can use width and height to define the size of the pie chart. The viewBox is the canvas we’re going to be drawing on.
  • For the circles you want to draw, you set the radius value to the half of the outcome circle’s radius (that is r="25" instead of 50) and set the stroke-width to be half the length of the viewBox (that is stroke-width="50"). By doing this, you will be drawing border of the circle with thickness equals to the length of the outcome circle’s radius, thus creating a solid circle.
  • Use stroke-dasharray to set the dash length and gap size between strokes.
  • The dash length will be the percentage of the circumference. For example, to draw 10% pie, it will be 0.1 * (2 * PI * 25) = 15.7
  • To ensure the border dash appears only once set the gap size to be the circumference minus the dash length (that is 157 - 15.7 = 141.3).
  • To draw the first slice of 10% use the percentage of the circumference and the gap size we just calculated with the expression stroke-dasharray="15.7 141.3"
  • Rotate the starting point from the default (100, 50) by 90 degrees or 25% of the circumference stroke-dashoffset="39.2".
  • If the pie is not starting at the 0% mark, then take the percentage of rotation into the calculation. Example, to start at 10% mark, the stroke-dashoffset will be (0.25 - 0.1) * CIRCUMFERENCE = 23.5.
  • To fill up any of the remaining area left, apply border-radius and background-color on <svg> element.

We can implement this as a React component to make it easier for other contributors to reuse:

const CIRCLE_RADIUS = 50 / 2;
const STROKE_WIDTH = CIRCLE_RADIUS * 2;
const CIRCUMFERENCE = 2 * Math.PI * CIRCLE_RADIUS;
const DASH_OFFSET = CIRCUMFERENCE / 4;
 
const PieChart = ({
 backgroundColor = 'WhiteSmoke',
 strokeColorOptions = ['LemonChiffon', 'LightGreen'],
 values = [],
}) => {
 const outputStrokeColors = _.times(values.length).map(
   (i) => strokeColorOptions[i % strokeColorOptions.length]
 );
 
 return (
   <svg
    backgroundColor={backgroundColor}
    viewBox="0 0 100 100"
    width="300px"
    height="300px"
    borderRadius="9999px"
   >
    {values.map((val, index) => {
     const strokeColor =
      outputStrokeColors[index % outputStrokeColors.length];
     const dashSize = (val / 100) * CIRCUMFERENCE;
     const gapSize = CIRCUMFERENCE - dashSize;
     // NOTE: if it's a full circle, then no need to provide a stroke dasharray
     const strokeDasharray =
      val === 100 ? 'none' : `${dashSize} ${gapSize}`;
     const accumulatedPriorPercentage = _.sum(values.slice(0, index));
     const relativeOffset =
       (accumulatedPriorPercentage / 100) * CIRCUMFERENCE;
     const adjustedOffset = DASH_OFFSET - relativeOffset;
 
     return (
     <circle
       key={index}
       cx={50}
       cy={50}
       fill="transparent"
       r={CIRCLE_RADIUS}
       stroke={strokeColor}
       strokeDasharray={strokeDasharray}
       strokeDashoffset={adjustedOffset}
       strokeWidth={STROKE_WIDTH}
      />
     );
    })}
   </svg>
 );
};

There might be a point where requirements change and we’ll need to adopt another solution or revisit existing libraries. Until then, the lightweight and in-scope versatility of the SVG <circle> solution will serve us well.