Although HTML5 Canvas and SVG might be more elegant solutions for building charts, in this tutorial we’ll learn how to build our very own donut chart with nothing but plain CSS.
To get an idea of what we’ll be creating, have a look at the embedded CodePen demo below:
HTML Markup
We start with some very basic markup; a plain unordered list with a span
element inside each of the list items:
<ul class="chart-skills"><li><span>CSS</span></li><li><span>HTML</span></li><li><span>PHP</span></li><li><span>Python</span></li></ul>
Adding Styles to the List
With the markup ready, first we apply some basic styles to the unordered list:
.chart-skills { position: relative; width: 350px; height: 175px; }
Then, we’re going to give each one an ::after
and a ::before
pseudo-element, and style them:
.chart-skills::before, .chart-skills::after { position: absolute; } .chart-skills::before { content: ''; width: inherit; height: inherit; border: 45px solid rgba(211,211,211,.3); border-bottom: none; border-top-left-radius: 175px; border-top-right-radius: 175px; } .chart-skills::after { content: 'Top Skills'; left: 50%; bottom: 10px; transform: translateX(-50%); font-size: 1.1rem; font-weight: bold; color: cadetblue; }
Pay attention to the styles for the ::before
pseudo-element. This gives us our half circle.

So far, the aforementioned rules give us this result:
Adding Styles to the List Items
Let’s now discuss the styling of the list items.
Positioning
With regards to the list items’ position, we do the following:
- Position them right underneath their parent and
- give them appropriate styles so as to create a reverse half circle.
Furthermore, a couple of things are worth noting here:
- The list items are absolutely positioned, thus we’re able to set their
z-index
property. - We modify the default
transform-origin
property value (i.e.transform-origin: 50% 50%
) of the list items. Specifically, we settransform-origin: 50% 0
. In this way, when we animate (rotate) the items, their center top corner will become the center of rotation.
Here are the associated CSS styles:
.chart-skills li { position: absolute; top: 100%; left: 0; width: inherit; height: inherit; border: 45px solid; border-top: none; border-bottom-left-radius: 175px; border-bottom-right-radius: 175px; transform-origin: 50% 0; } .chart-skills li:nth-child(1) { z-index: 4; border-color: green; } .chart-skills li:nth-child(2) { z-index: 3; border-color: firebrick; } .chart-skills li:nth-child(3) { z-index: 2; border-color: steelblue; } .chart-skills li:nth-child(4) { z-index: 1; border-color: orange; }
Take a look at what we've built so far in the next visualization:

Currently, the only list item which is visible is the green one (which has z-index: 4;
) the others are underneath it.
Animations
Before we cover the steps for animating our list items, let’s take note of the desired percentage for each item (ie: how much of the donut each will cover). Consider the following table:
Language | Percentage |
---|---|
CSS | 12 |
HTML | 32 |
PHP | 34 |
Python | 22 |
Next, we calculate how many degrees we have to animate (rotate) each of the items. To find out the exact number of degrees for each item, we multiply its percentage by 180° (not 360° because we’re using a semi-circle donut chart):
Language | Percentage | Number of Degrees |
---|---|---|
CSS | 12 | 12/100 * 180 = 21.6 |
HTML | 32 | 32/100 * 180 = 57.6 |
PHP | 34 | 34/100 * 180 = 61.2 |
Python | 22 | 22/100 * 180 = 39.6 |
At this point we’re ready to set up the animations. First, we define some animation styles that are shared across all items, by adding some rules to .chart-skills li
:
animation-fill-mode: forwards; animation-duration: .4s; animation-timing-function: linear;
Then, we define the unique animation styles:
.chart-skills li:nth-child(1) { z-index: 4; border-color: green; animation-name: rotate-one; } .chart-skills li:nth-child(2) { z-index: 3; border-color: firebrick; animation-name: rotate-two; animation-delay: .4s; } .chart-skills li:nth-child(3) { z-index: 2; border-color: steelblue; animation-name: rotate-three; animation-delay: .8s; } .chart-skills li:nth-child(4) { z-index: 1; border-color: orange; animation-name: rotate-four; animation-delay: 1.2s; }
Notice that we add a delay to all items except for the first one. In this way, we create nice sequential animations. For example, when the animation of the first element finishes, the second element appears, and so on.
The next step is to specify the actual animations:
@keyframes rotate-one { 100% { transform: rotate(21.6deg); /** * 12% => 21.6deg */ } } @keyframes rotate-two { 0% { transform: rotate(21.6deg); } 100% { transform: rotate(79.2deg); /** * 32% => 57.6deg * 57.6 + 21.6 => 79.2deg */ } } @keyframes rotate-three { 0% { transform: rotate(79.2deg); } 100% { transform: rotate(140.4deg); /** * 34% => 61.2deg * 61.2 + 79.2 => 140.4deg */ } } @keyframes rotate-four { 0% { transform: rotate(140.4deg); } 100% { transform: rotate(180deg); /** * 22% => 39.6deg * 140.4 + 39.6 => 180deg */ } }
Before going any further, we’ll briefly look at how the animations work:
The first element goes from transform: none
to transform: rotate(21.6deg)
.

The second element goes from transform: rotate(21.6deg)
(starts from the final position of the first element) to transform: rotate(79.2deg)
(57.6deg + 21.6deg).

The third element goes from transform: rotate(79.2deg)
(starts from the final position of the second element) to transform: rotate(140.4deg)
(61.2deg + 79.2deg).

The fourth element goes from transform: rotate(140.4deg)
(starts from the final position of the third element) to transform: rotate(180deg)
(140.4deg + 39.6deg).

Hide!
Last but not least, to hide the bottom half of the chart, we have to add the following rules:
.chart-skills { /* existing rules....*/ overflow: hidden; } .chart-skills li { /* existing rules....*/ transform-style: preserve-3d; backface-visibility: hidden; }
The overflow: hidden
property value ensures that only the first semi-circle (the one created with the ::before
pseudo-element) is visible. Feel free to remove that property if you want to test the initial position of the list items.
The transform-style: preserve-3d
and backface-visibility: hidden
properties prevent flickering effects that may occur in different browsers due to animations. If this problem still exists in your browser, you may want to try these solutions as well.
The chart is almost ready! All that remains is to style the chart labels, which we’ll do in the next section.
Here’s the CodePen demo showing the current appearance of our chart:
Adding Styles to the Labels
In this section, we’ll style the chart labels.
Positioning
With regards to their position, we do the following:
- Give them
position: absolute
and use thetop
andleft
properties to set their desired position. - Use negative values to rotate them. Of course, these aren’t random values. In fact, these are extracted from the last frame of their parent item. For instance, the last frame of the second list item includes
transform: rotate(79.2deg)
, and thus its related label will havetransform: rotate(-79.2deg)
.
Below are the corresponding CSS styles:
.chart-skills span { position: absolute; font-size: .85rem; } .chart-skills li:nth-child(1) span { top: 5px; left: 10px; transform: rotate(-21.6deg); } .chart-skills li:nth-child(2) span { top: 20px; left: 10px; transform: rotate(-79.2deg); } .chart-skills li:nth-child(3) span { top: 18px; left: 10px; transform: rotate(-140.4deg); } .chart-skills li:nth-child(4) span { top: 10px; left: 10px; transform: rotate(-180deg); }
Animations
Now that we’ve positioned the labels, it’s time to animate them. Two things are worth mentioning here:
- By default, all labels are hidden and become visible as their parent item is being animated.
- Similarly to the parent items, we use the
animation-delay
property to create sequential animations. In addition, we add thebackface-visibility: hidden
property value to ensure that there aren’t any flickering effects due to animations.
The CSS rules that deal with the animation of the chart labels are shown below:
.chart-skills span { backface-visibility: hidden; animation: fade-in .4s linear forwards; } .chart-skills li:nth-child(2) span { animation-delay: .4s; } .chart-skills li:nth-child(3) span { animation-delay: .8s; } .chart-skills li:nth-child(4) span { animation-delay: 1.2s; } @keyframes fade-in { 0%, 90% { opacity: 0; } 100% { opacity: 1; } }
Here’s the final chart:
Browser Support & Issues
In general, the demo works well in all browsers. I just want to discuss two small issues that are related to the border-radius
property.
First, if we were to give different colors to our items, the chart might look something like this:

Notice for example the top and bottom corners of the third item. There are two red lines which come from the border color of the fourth item. We can see those lines because the fourth item has a darker border color compared to the third one. Although this is a small issue, it’s good to be aware of it in order to choose appropriate colors for your own charts.
Secondly, in Safari the chart appears as follows:

Look at the small gaps appearing in the second and third items. If you know anything regarding this issue, let us know in the comments below!
Conclusion
In this tutorial, we went through the process of creating a semi-circle donut chart with pure CSS. Again, as mentioned in the introduction, there are potentially more powerful solutions (e.g. HTML5 Canvas and SVG) out there for creating these kind of things. However, if you want to build something simple and lightweight, and enjoy a challenge, CSS is the way to go!