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?
-
On each header
<th>
, a --color custom property allows you to assign, well… a color. -
Then each cell
<td>
must contain the value and its unit, as well as astyle
attribute to carry some variables:-
--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 toclip-path
— depend on this value (read the technical note after the example for details of the calculations). -
--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 thetransform
poperty. -
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;
-
--value is the percentage value,
useful for determining the angle the element should occupy on the circle.
All points of the
-
a pseudo-element on each cell
<td>
is formatted in a clever way according to all our variables, includingtransform
,clip-path
andmask-image
.-
Since
clip-path
still requires a-webkit-
vendor prefix for Safari andmask-image
for WebKit based browsers, we use a CSS custom property to to prevent duplicate (long) values for those properties — trick shared by Michelle Barker in "7 uses for CSS custom properties".
-
Since
- 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.
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:
- the center of the circle,
- the point we're trying to position,
- 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:
-
The initial axis, from centre upwards:
50% 50%
and50% 0%
. -
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 be50% 50%
if --gt-25 is 0, and100% 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 value50% 50%
to switch to the right point.
-
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:
-
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 invar()
? 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%
or100%
as appropriate.
- we start from
-
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.
-
Rendering example for 44% Shape screenshot for 44%. td[style*="--value: 44;"]::before { clip-path: polygon( 50% 50%, 50% 0%, 100% 0%, 100% 50%, 100% 100%, 95.2413525% 71.2889645%, 50% 50%, 50% 50%, 50% 50%, 50% 50%, 50% 50% ); }
Code generated for 44%. -
Rendering example for 64% Shape screenshot for 64%. td[style*="--value: 64;"]::before { clip-path: polygon( 50% 50%, 50% 0%, 100% 0%, 100% 50%, 100% 100%, 100% 100%, 0% 100%, 11.474338% 81.8711995%, 50% 50%, 50% 50%, 50% 50% ); }
Code generated for 64%. -
Rendering example for 88% Shape screenshot for 88%. td[style*="--value: 88;"]::before { clip-path: polygon( 50% 50%, 50% 0%, 100% 0%, 100% 50%, 100% 100%, 100% 100%, 0% 100%, 0% 50%, 0% 0%, 15.7726445% 13.5515685%, 50% 50% ); }
Code generated for 88%.
Switch
Allows you to disable styles on the following table.
Resource | Proportion |
---|---|
HTML | 2 % |
CSS | 2 % |
JS | 32 % |
Images | 64 % |
Switch
Allows you to disable styles on the following table.
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.
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.
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;
}