Pie charts

The pie chart is used for representations of percentage proportions. It relies on CSS variables, an outrageous abuse of calc(), display: table-*, clip-path, mask-image, transform and a bit of SVG to distinguish each area. Yes, I know how to laugh. How do we use it?

  1. On each header <th>, a --color custom property allows you to assign, well… a color.
  2. Then each cell <td> must contain the value and its unit, as well as a style attribute to carry some variables:
    1. --value is the percentage value, useful for determining the angle the element should occupy on the circle. All points of the polygon() — used to draw the pie part thanks to clip-path — depend on this value (read the technical note after the example for details of the calculations).
    2. --start is used to define the starting point of the arc on the circle. It's the sum of the previous definitions, and is applied to the rotate() function of the transform poperty.
    3. And finally a series of pseudo-boolean variables, each worthing 0 or 1 — according to and idea of Roman Komarov in "Conditions for CSS variables" — depend on the value: --lt-25, --gt-25, --lt-50… They allow to toggle the points from their original position (50% 50%) to their calculated position, by adding or subtracting from the initial value;
  3. a pseudo-element on each cell <td>is formatted in a clever way according to all our variables, including transform, clip-path and mask-image.
  4. And finally a pattern is applied to the background, in order to better associate it visually with the corresponding legend.

Switch
Allows you to disable styles on the following table.

Distribution of the weight of resources for ffoodd.fr
Resource Proportion
HTML 2 %
CSS 2 %
JS 32 %
Json 1 %
Images 44 %
Webfonts 17 %
Other 2 %
A bit of trigonometry

In this graph, each portion represents an arc of a circle based on an angle (part of 360 degrees). To define the shape of this portion, a point must be placed on the circle.

To do this, I divide the circle into four squares. The position of the point on the circle can then be calculated using the properties of the right-angled triangle formed by:

  1. the center of the circle,
  2. the point we're trying to position,
  3. and the point perpendicular to the radius and passing through our target point.

We know the hypotenuse of this triangle — the radius of the circle —, and the angle formed by the hypotenuse and starting from the center of the circle (reducing the value to 90 degrees, since the circle is divided into four square sectors: if the value is greater than 25: minus 90°, etc.) — plus a right angle, of course.

Law of sines

We can therefore use the sine law to measure each side, and thus obtain the position of the point on the circle. Meaning we need to calculate the sine… Fortunately, Stereokai implemented for us the Taylor/Maclaurin polynomial representation in CSS — which I implemented as a mixin:

@mixin sin($angle) {
  --sin-term-#{$angle}-1: var(--#{$angle});
  --sin-term-#{$angle}-2: calc((var(--#{$angle}) * var(--#{$angle}) * var(--#{$angle})) / 6);
  --sin-term-#{$angle}-3: calc((var(--#{$angle}) * var(--#{$angle}) * var(--#{$angle}) * var(--#{$angle}) * var(--#{$angle})) / 120);
  --sin-term-#{$angle}-4: calc((var(--#{$angle}) * var(--#{$angle}) * var(--#{$angle}) * var(--#{$angle}) * var(--#{$angle}) * var(--#{$angle}) * var(--#{$angle})) / 5040);
  --sin-term-#{$angle}-5: calc((var(--#{$angle}) * var(--#{$angle}) * var(--#{$angle}) * var(--#{$angle}) * var(--#{$angle}) * var(--#{$angle}) * var(--#{$angle}) * var(--#{$angle}) * var(--#{$angle})) / 362880);
  --sin-#{$angle}: calc(var(--sin-term-#{$angle}-1) - var(--sin-term-#{$angle}-2) + var(--sin-term-#{$angle}-3) - var(--sin-term-#{$angle}-4) + var(--sin-term-#{$angle}-5));
}

All that remains is to use these dimensions to place the points of the polygon. It's child's play!

HTML
<table class="chaarts pie">
  <caption id="caption-5">[…]</caption>
  <thead class="sr-only">
    <tr>
      <th scope="col">[…]</th>
      <th scope="col">[…]</th>
    </tr>
  </thead>
  <tbody>
    <tr style="--color: #734bf9; --term: 'HTML';">
      <th scope="row">HTML</th>
      <td style="--value: 2; --start: 0;">2 %</td>
    </tr>
    <tr>[…]</tr>
  </tbody>
</table>
css
   
@supports (clip-path: polygon( 50% calc( 50% + ( var(--gt-25, 0) ) ) )) {
 .chaarts.pie {
   --radius: 32em;
   margin: 0 auto;
   padding-top: calc(var(--radius) - 2rem);
   position: relative;
 }

 .chaarts.pie tbody {
   display: table-row;
 }

 .chaarts.pie tr {
   display: table-cell;
   transition: opacity .3s cubic-bezier(.5, 0, .5, 1);
 }

 .chaarts.pie [scope="row"] {
   padding-right: .5rem;
 }

 .chaarts.pie [scope="row"]::before {
   background: var(--color, currentColor);
   content: "";
   display: inline-block;
   height: 1rem;
   transform: translate3d(-.2rem, .1rem, 0);
   width: 1rem;
 }

 .chaarts.pie td {
   --position: calc(var(--start, 0) * .01turn);
 }

 .chaarts.pie td::after,
 .chaarts.pie td::before {
   left: 50%;
   position: absolute;
   top: 40%;
   transform-origin: center center;
 }

 .chaarts.pie td::before {
   /* The inclination, to be in the right place */
   --position: calc(var(--start, 0) * .01turn);
   --zoom: .75;
   /* The angle represented by the value: 3.6 = 360deg / 100 */
   /* Since we're using a percentage value */
   --part: calc( var(--value) * 3.6 );
   /* The "useful" angle for the calculation, necessarily less than 90deg */
   /*  We therefore subtract 90deg (= ¼ × 360deg) per 25% (= ¼ × 100%, indeed) */
   --main-angle: calc( var(--part) - ( 90 * ( var(--gt-25, 0) + var(--gt-50, 0) + var(--gt-75, 0) ) ) );
   /* Main angle, in radian */
   --β: calc( var(--main-angle) * 0.01745329251 );
   /* The last angle in radian, by deduction since in a right-angled triangle */
   --α: calc( ( 90 - var(--main-angle) ) * 0.01745329251 );
   /* The magic of Stereokai, to get the sinus from these angles… */
   --sin-term-β-1: var(--β);
   --sin-term-β-2: calc((var(--β) * var(--β) * var(--β)) / 6);
   --sin-term-β-3: calc((var(--β) * var(--β) * var(--β) * var(--β) * var(--β)) / 120);
   --sin-term-β-4: calc((var(--β) * var(--β) * var(--β) * var(--β) * var(--β) * var(--β) * var(--β)) / 5040);
   --sin-term-β-5: calc((var(--β) * var(--β) * var(--β) * var(--β) * var(--β) * var(--β) * var(--β) * var(--β) * var(--β)) / 362880);
   --sin-β: calc(var(--sin-term-β-1) - var(--sin-term-β-2) + var(--sin-term-β-3) - var(--sin-term-β-4) + var(--sin-term-β-5));
   --sin-term-α-1: var(--α);
   --sin-term-α-2: calc((var(--α) * var(--α) * var(--α)) / 6);
   --sin-term-α-3: calc((var(--α) * var(--α) * var(--α) * var(--α) * var(--α)) / 120);
   --sin-term-α-4: calc((var(--α) * var(--α) * var(--α) * var(--α) * var(--α) * var(--α) * var(--α)) / 5040);
   --sin-term-α-5: calc((var(--α) * var(--α) * var(--α) * var(--α) * var(--α) * var(--α) * var(--α) * var(--α) * var(--α)) / 362880);
   --sin-α: calc(var(--sin-term-α-1) - var(--sin-term-α-2) + var(--sin-term-α-3) - var(--sin-term-α-4) + var(--sin-term-α-5));
   /* Finally, the position expressed in %, of the hypothenuse, divided by 2 to fit in ¼ of the circle
    * or after simplification, divided by 50 */
   --pos-B: calc( var(--sin-β) * 50 );
   --pos-A: calc( var(--sin-α) * 50 );
   background-color: var(--color, currentColor);
   --polygon: polygon(
     50% 50%,
     50% 0%,
     100% 0%,
     calc( 50% + ( var(--pos-B) * 1% * var(--lt-25, 1) ) + ( var(--gt-25, 0) * 50% ) ) calc( 50% - ( var(--pos-A) * 1% * var(--lt-25, 1) ) ),
     calc( 50% + ( var(--gt-25, 0) * 50% ) )                      calc( 50% + ( var(--gt-25, 0) * 50% ) ),
     calc( 50% + ( var(--pos-A) * 1% * var(--lt-50, 1) ) + ( var(--gt-50, 0) * 50% ) ) calc( 50% + ( var(--pos-B) * 1% * var(--lt-50, 1) ) + ( var(--gt-50, 0) * 50% ) ),
     calc( 50% - ( var(--gt-50, 0) * 50% ) )                      calc( 50% + ( var(--gt-50, 0) * 50% ) ),
     calc( 50% - ( var(--pos-B) * 1% * var(--lt-75, 1) ) - ( var(--gt-75, 0) * 50% ) ) calc( 50% + ( var(--pos-A) * 1% * var(--lt-75, 1) ) ),
     calc( 50% - ( var(--gt-75, 0) * 50% ) )                      calc( 50% - ( var(--gt-75, 0) * 50% ) ),
     calc( 50% - ( var(--pos-A) * 1% * var(--gt-75, 0) ) )        calc( 50% - ( var(--pos-B) * 1% * var(--gt-75, 0) ) ),
     50% 50%
   );
   clip-path: var(--polygon);
   content: '';
   height: var(--radius) ;
   left: 50%;
   --mask: radial-gradient(
     circle at center,
     white 0%,
     white calc(var(--radius)  / 2),
     transparent calc(var(--radius)  / 2)
   );
   mask-image: var(--mask);
   position: absolute;
   top: 40%;
   transform:
     translate3d(-50%, -50%, 0)
     rotate( var(--position) )
     scale( var(--zoom) );
   transform-origin: center center;
   transition: transform .2s cubic-bezier(.5, 0, .5, 1);
   width: var(--radius);
 }

 .chaarts.pie tr:hover td::before {
   --zoom: .8;
 }

 .chaarts.pie tr:nth-child(2n + 2) *::before {
   background-image: var(--stripes);
 }

 .chaarts.pie tbody:hover tr {
   opacity: .75;
 }

 .chaarts.pie tbody:hover tr:hover {
   opacity: 1;
 }

 @media screen and (-ms-high-contrast: active) {
   .chaarts.pie tr *::before {
     background-color: Window;
   }

   .chaarts.pie tr:nth-of-type(odd) *::before {
     background-color: WindowText;
   }
 }
}
  
The calculation twist

The positions of the polygon

The use of pseudo-Boolean variables makes this calculation pseudo-algorithmic. Let's start with an essential pre-requisite: the polygon being a closed shape and CSS not being magical, points must pre-exist. Spoiler, we need eleven points:

  1. The initial axis, from centre upwards: 50% 50% and 50% 0%.
  2. One point for each angle at the ends: the first one is fixed, at 100% 0% (top right) — then each of the other angles has two states, reached or not reached. Some insights:
    • For example, the point at the bottom right concerns values between 25% and 50%: if the value is less than 25%, it must be in the centre (so as not to interfere with the drawing), and if not, it must be in its corner: calc( 50% + ( var(--gt-25, 0) * 50% ) ) calc( 50% + ( var(--gt-25, 0) * 50% ) )
      Thus the calculated value will be 50% 50% if --gt-25 is 0, and 100% 100% if --gt-25 is 1.
    • In addition, each angle has its target coordinate: 100% 100% for bottom right, 0% 100% for bottom left, 0% 0% for top left. It is therefore necessary to sometimes subtract and sometimes add to the initial value 50% 50% to switch to the right point.
  3. One point for each possible position per quarter circle, corresponding to each 25% slice. Like the points at the corners, these points must be in the centre if they are not used. That's where we laugh the most:
    • we start from 50%, to which we add or subtract the following calculation;
    • then the calculated position is used — --pos-A or --pos-B as the case may be — which is converted into percentages using * 1%, and rendered inert if the value is less than the range concerned using * var(--lt-25, 1), for example.
      Notice the second value in var()? This is the default value if the variable is not defined. Cool, isn't it?
    • finally when the range is exceeded, the point switches to 0% or 100% as appropriate.
  4. And lastly, we lose the path by going back to the centre of the circle, at 50% 50%.

That's it!

The positions illustrated

These screenshots — taken in the shape editor of the Firefox devtools — show the points of the polygon in the different cases. You can see for each cited value the resolved polygon for clip-path — and see how the dynamic values tilt from one position to another.

Switch
Allows you to disable styles on the following table.

Pie chart example with a value between 50 and 75%
Resource Proportion
HTML 2 %
CSS 2 %
JS 32 %
Images 64 %

Switch
Allows you to disable styles on the following table.

Pie chart example with a value greater than 75%
Resource Proportion
HTML 2 %
CSS 2 %
JS 8 %
Images 88 %

Donut

On the <table> element, we add an --offset variable that allows us to determine the size of the hole of the donut, generated using mask-image and radial-gradient(). Ana Tudor has made many examples of using mask-* on CodePen, have a look!

Switch
Allows you to disable styles on the following table.

Distribution of the weight of resources for ffoodd.fr
Resource Proportion
HTML 2 %
CSS 2 %
JS 32 %
Json 1 %
Images 44 %
Webfonts 17 %
Other 2 %
css
.chaarts.donut {
  --mask: radial-gradient(
    circle at 50% calc(50% - 2rem),
    transparent 0%,
    transparent var(--offset),
    white calc(var(--offset) + 1px),
    white 100%
  );
  mask-image: var(--mask);
}

.chaarts.donut td::after {
  --away: calc( var(--radius) / 2 - 2.5rem );
}

Conical gradient

The use of conic-gradient() is promising for this case. You'll find examples made by Ana Tudor and Léa Verou — who actually wrote the specification, and designed a polyfill. However, current support is limited to WebKit based browsers is depressing, and still raises some accessibility issues since you can't assign a pattern to each color of the conic-gradient().

Polar chart

For this variant, wa change almost nothing: only the --zoom variable and its implication in the scaling of portions using scale().

Switch
Allows you to disable styles on the following table.

Distribution of the weight of resources for ffoodd.fr
Resource Proportion
HTML 2 %
CSS 2 %
JS 32 %
Json 1 %
Images 44 %
Webfonts 17 %
Other 2 %
css
.chaarts.polar td::before {
  --zoom: 50;
  transform:
    translate3d( -50%, -50%, 0 )
    rotate( var(--position) )
    scale( calc( ( var(--zoom) + ( var(--value) / ( 100 / var(--zoom) ) ) ) / 100 ) );
}

.chaarts.polar tr:hover td::before {
  --zoom: 50;
}

.chaarts.polar tbody:hover tr {
  opacity: .5;
}