How to Build an Animated CSS Thermometer Chart
So far in our series of CSS chart tutorials I’ve shown you how to build a column chart, a bar chart, and a semi-circle donut chart.
In this tutorial I’ll cover the process of creating a pure CSS animated “thermometer” chart; the perfect way to track achievement of a single target.
What We’re Building
Here’s the chart we’ll be creating (hit reload to see it animate):
1. Begin With The Data
For the purposes of this demo, we’ll need some data. Let’s work with some fictional figures that describe the funding of a charitable organization over the years:
| Year | Funding |
|---|---|
| 2018 | €95,000 |
| 2016 | €72,000 |
| 2015 | €50,000 |
| 2012 | €35,000 |
| 2010 | €15,000 |
As you might’ve guessed, our challenge is to visualize this data in a thermometer chart. We’ll be taking our visual inspiration from those troublesome do-gooders Extinction Rebellion (if you haven’t heard of them, check out what they’re up to!).
2. Specify the Page Markup
We’ll specify a wrapper element which contains three lists:
- The first list sets the y-axis range. If you take a closer look at the table data above, you’ll see that the second column includes values up to 95,000. Keeping this in mind, we’ll define six values from 0 to 100,000 with a step size of 20,000. The values of the y-axis will therefore be 0, 20,000, 40,000, 60,000, 80,000, and 100,000.
- The second list sets the x-axis data. These are extracted from the first column of our table, from lowest to highest. But their order doesn’t matter because we’ll absolutely position them. Notice in the markup below though, that a list item contains the same year twice. We could’ve omitted setting the year as the item’s text node. But it’s important to store this value in the
data-yearattribute. As we’ll see later, we’ll pass the value of this attribute to the related::beforepseudo-element. - The third list groups the aforementioned table data, from the highest funding value to the lowest one. Again here the order isn’t important. You can show them from lowest to highest as well. This is completely up to you.
Here’s the required markup:
1 |
<div class="chart-wrapper"> |
2 |
<ul class="chart-y"> |
3 |
<li>€100,000</li> |
4 |
... |
5 |
</ul>
|
6 |
<ul class="chart-x"> |
7 |
<li data-year="2010">2010</li> |
8 |
... |
9 |
</ul>
|
10 |
<ul class="chart-labels"> |
11 |
<li>2018 - €95,000</li> |
12 |
... |
13 |
</ul>
|
14 |
</div>
|
3. Define Some Basic Styles
Coming up next, we’ll set up a few CSS variables and some common reset styles:
1 |
@font-face { |
2 |
font-family: "Cheddar Gothic Sans"; |
3 |
src: url("cheddargothic-sans-webfont.woff2") format("woff2"), |
4 |
url("cheddargothic-sans-webfont.woff") format("woff"); |
5 |
}
|
6 |
|
7 |
:root { |
8 |
--brand-color: #21a73d; |
9 |
--chart-bg-color: rgba(211, 211, 211, .3); |
10 |
--chart-line-color: black; |
11 |
--chart-x-color: white; |
12 |
--line-color1: crimson; |
13 |
--line-color2: gold; |
14 |
--line-color3: firebrick; |
15 |
--line-color4: orange; |
16 |
--line-color5: darkblue; |
17 |
--black: #2d2929; |
18 |
--white: white; |
19 |
--transition-delay: 0.6s; |
20 |
--transition-delay-step: 0.6s; |
21 |
}
|
22 |
|
23 |
* { |
24 |
padding: 0; |
25 |
margin: 0; |
26 |
box-sizing: border-box; |
27 |
}
|
28 |
|
29 |
ul { |
30 |
list-style: none; |
31 |
}
|
32 |
|
33 |
a { |
34 |
text-decoration: none; |
35 |
color: inherit; |
36 |
}
|
37 |
|
38 |
body { |
39 |
font: 1rem/1.2 Georgia, serif; |
40 |
padding-top: 70px; |
41 |
background: var(--brand-color); |
42 |
}
|
Nothing really spectacular happens here, but perhaps one thing to note is that apart from the well-known “Georgia” system serif font, I’ve also used the premium “Cheddar Gothic” font taken from Envato Elements.



Note: For simplicity I won’t walk through all the CSS rules in the tutorial. You can check the rest of them by clicking the CSS tab of the demo project.
4. Style the Chart
The chart wrapper will be a grid container with horizontally centered content. Each column (list) will have its default width, with a 4rem gap between them.



Here’s the related CSS:
1 |
.chart-wrapper { |
2 |
display: grid; |
3 |
justify-content: center; |
4 |
grid-column-gap: 4rem; |
5 |
grid-template-columns: auto auto auto; |
6 |
}
|
The y-axis
The first list which contains the y-axis data will also behave as a grid container. There’ll be a 3rem gap between the items:

Here’s the associated CSS:
1 |
.chart-wrapper .chart-y { |
2 |
display: grid; |
3 |
grid-row-gap: 3rem; |
4 |
}
|
The x-axis
The second list which includes the x-axis data will have a fixed width of 50px. Furthermore, we’ll give it some additional styles so it’ll have the appearance of a real thermometer:

The corresponding styles are as follows:
1 |
/*CUSTOM VARIABLES HERE*/
|
2 |
|
3 |
.chart-wrapper .chart-x { |
4 |
position: relative; |
5 |
width: 50px; |
6 |
border-radius: 25px; |
7 |
border: 8px solid var(--chart-line-color); |
8 |
background: var(--chart-bg-color); |
9 |
overflow: hidden; |
10 |
}
|
The list items will be absolutely positioned with an initial height of 0–hence by default, they’ll be invisible. Another thing we do is set their foreground color as transparent. In such a case, their text which contains the year won’t be visible:
1 |
/*CUSTOM VARIABLES HERE*/
|
2 |
|
3 |
.chart-wrapper .chart-x li { |
4 |
position: absolute; |
5 |
left: 0; |
6 |
bottom: 0; |
7 |
width: 100%; |
8 |
height: 0; |
9 |
color: transparent; |
10 |
border-bottom-left-radius: inherit; |
11 |
border-bottom-right-radius: inherit; |
12 |
background: var(--chart-x-color); |
13 |
transition: height 0.5s ease-out; |
14 |
}
|
For the next step we’ll specify some styles for their ::before pseudo-element. Remember its content will include the value of the associated data-year attribute (see markup above). Plus, it’ll be initially hidden:

The required styles:
1 |
/*CUSTOM VARIABLES HERE*/
|
2 |
|
3 |
.chart-wrapper .chart-x li::before { |
4 |
content: attr(data-year); |
5 |
position: absolute; |
6 |
top: 0; |
7 |
left: 0; |
8 |
right: 0; |
9 |
z-index: 100; |
10 |
border-top: 4px solid; |
11 |
width: 20px; |
12 |
opacity: 0; |
13 |
padding-left: 3px; |
14 |
color: var(--black); |
15 |
font-size: 0.75rem; |
16 |
transition: opacity 0.5s ease-out; |
17 |
}
|
18 |
|
19 |
.chart-wrapper .chart-x li:nth-child(1)::before { |
20 |
border-color: var(--line-color1); |
21 |
}
|
22 |
|
23 |
.chart-wrapper .chart-x li:nth-child(2)::before { |
24 |
border-color: var(--line-color2); |
25 |
}
|
26 |
|
27 |
.chart-wrapper .chart-x li:nth-child(3)::before { |
28 |
border-color: var(--line-color3); |
29 |
}
|
30 |
|
31 |
.chart-wrapper .chart-x li:nth-child(4)::before { |
32 |
border-color: var(--line-color4); |
33 |
}
|
34 |
|
35 |
.chart-wrapper .chart-x li:nth-child(5)::before { |
36 |
border-color: var(--line-color5); |
37 |
}
|
Animate the Items
As soon as the page loads, the height of the list items will be animated and receive a value that’ll be determined by the associated funding value (see table above). For example, a value of 15,000 corresponds to height: 15%. Besides the list items, their ::before pseudo-element will be animated as well, though the animations won’t run simultaneously. The item will become visible first, then its pseudo-element.
Let’s now turn all the requirements above into code.
When the page loads, we’ll first add the loaded class to the body:
1 |
window.addEventListener("load", () => { |
2 |
document.body.classList.add("loaded"); |
3 |
});
|
At that point the items can be animated sequentially. To achieve this, we’ll take advantage of a technique used in my previous chart demos. We’ll define two CSS variables that determine the transition speed (feel free to change them if needed) and we’ll combine them with the calc() function.
Here are the CSS styles responsible for revealing the x-axis items:
1 |
/*CUSTOM VARIABLES HERE*/
|
2 |
|
3 |
.loaded .chart-wrapper .chart-x li:nth-child(1) { |
4 |
height: 15%; /*represents €15,000*/ |
5 |
transition-delay: var(--transition-delay); |
6 |
}
|
7 |
|
8 |
.loaded .chart-wrapper .chart-x li:nth-child(2) { |
9 |
height: 35%; /*represents €35,000*/ |
10 |
transition-delay: calc( |
11 |
var(--transition-delay) + var(--transition-delay-step) |
12 |
);
|
13 |
}
|
14 |
|
15 |
.loaded .chart-wrapper .chart-x li:nth-child(3) { |
16 |
height: 50%; /*represents €50,000*/ |
17 |
transition-delay: calc( |
18 |
var(--transition-delay) + var(--transition-delay-step) * 2 |
19 |
);
|
20 |
}
|
21 |
|
22 |
.loaded .chart-wrapper .chart-x li:nth-child(4) { |
23 |
height: 72%; /*represents €72,000*/ |
24 |
transition-delay: calc( |
25 |
var(--transition-delay) + var(--transition-delay-step) * 3 |
26 |
);
|
27 |
}
|
28 |
|
29 |
.loaded .chart-wrapper .chart-x li:nth-child(5) { |
30 |
height: 95%; /*represents €95,000*/ |
31 |
transition-delay: calc( |
32 |
var(--transition-delay) + var(--transition-delay-step) * 4 |
33 |
);
|
34 |
}
|
As already mentioned in previous chart demos, Microsoft Edge doesn’t support these math operations, so if you need to support it, you’ll have to pass some static values instead, like this:
1 |
.loaded .chart-wrapper .chart-x li:nth-child(2) { |
2 |
height: 15%; |
3 |
transition-delay: 1.2s; |
4 |
}
|
5 |
|
6 |
.loaded .chart-wrapper .chart-x li:nth-child(3) { |
7 |
height: 35%; |
8 |
transition-delay: 1.8s; |
9 |
}
|
Note: I could have defined the transition-delay property outside the loaded class like this:
1 |
.chart-wrapper .chart-x li:nth-child(1) { |
2 |
transition-delay: var(--transition-delay); |
3 |
}
|
In either case, the result will be the same; the animation won’t run until the page loads. I only chose to include that property within the loaded class because I wanted to limit the CSS code and group the properties that share the same selector, for example those two:
1 |
.loaded .chart-wrapper .chart-x li:nth-child(1) { |
2 |
height: 15%; /*represents €15,000*/ |
3 |
}
|
4 |
|
5 |
.chart-wrapper .chart-x li:nth-child(1) { |
6 |
transition-delay: var(--transition-delay); |
7 |
}
|
As already discussed in previous steps, the ::before pseudo-element should also appear on page load:
1 |
.loaded .chart-wrapper .chart-x li::before { |
2 |
opacity: 1; |
3 |
}
|
But with an even bigger delay:
1 |
/*CUSTOM VARIABLES HERE*/
|
2 |
|
3 |
.chart-wrapper .chart-x li:nth-child(1)::before { |
4 |
transition-delay: calc( |
5 |
var(--transition-delay) + var(--transition-delay-step) |
6 |
);
|
7 |
}
|
8 |
|
9 |
.chart-wrapper .chart-x li:nth-child(2)::before { |
10 |
transition-delay: calc( |
11 |
var(--transition-delay) + var(--transition-delay-step) * 2 |
12 |
);
|
13 |
}
|
14 |
|
15 |
.chart-wrapper .chart-x li:nth-child(3)::before { |
16 |
transition-delay: calc( |
17 |
var(--transition-delay) + var(--transition-delay-step) * 3 |
18 |
);
|
19 |
}
|
20 |
|
21 |
.chart-wrapper .chart-x li:nth-child(4)::before { |
22 |
transition-delay: calc( |
23 |
var(--transition-delay) + var(--transition-delay-step) * 4 |
24 |
);
|
25 |
}
|
26 |
|
27 |
.chart-wrapper .chart-x li:nth-child(5)::before { |
28 |
transition-delay: calc( |
29 |
var(--transition-delay) + var(--transition-delay-step) * 5 |
30 |
);
|
31 |
}
|
The labels
The third list which includes the table data will be hidden by default.

But at the moment the chart animations finish, it will appear:
1 |
.chart-wrapper .chart-labels { |
2 |
opacity: 0; |
3 |
transition: opacity .6s 3.8s; |
4 |
}
|
5 |
|
6 |
.loaded .chart-wrapper .chart-labels { |
7 |
opacity: 1; |
8 |
}
|
Notice the value of the transition-delay property which is set to 3.8s. We’ll use this value because the transition-delay property of the last ::before element of the x-axis is set to 3.6s:



Finally, we’ll use the ::before pseudo-element of its list items to create the colorful rectangle which appears in the left corner of each item:

Here are the styles needed for that:
1 |
/*CUSTOM VARIABLES HERE*/
|
2 |
|
3 |
.chart-wrapper .chart-labels li::before { |
4 |
content: ''; |
5 |
display: inline-block; |
6 |
vertical-align: middle; |
7 |
width: 20px; |
8 |
height: 20px; |
9 |
margin-right: 10px; |
10 |
}
|
11 |
|
12 |
.chart-wrapper .chart-labels li:nth-child(1)::before { |
13 |
background: var(--line-color5); |
14 |
}
|
15 |
|
16 |
.chart-wrapper .chart-labels li:nth-child(2)::before { |
17 |
background: var(--line-color4); |
18 |
}
|
19 |
|
20 |
.chart-wrapper .chart-labels li:nth-child(3)::before { |
21 |
background: var(--line-color3); |
22 |
}
|
23 |
|
24 |
.chart-wrapper .chart-labels li:nth-child(4)::before { |
25 |
background: var(--line-color2); |
26 |
}
|
27 |
|
28 |
.chart-wrapper .chart-labels li:nth-child(5)::before { |
29 |
background: var(--line-color1); |
30 |
}
|
Our final project looks like this!
Conclusion
That’s it folks! In this tutorial, we built a pure CSS animated thermometer chart. I hope you found this exercise interesting and that it will challenge you to create your own CSS charts.
As always, thanks for reading!
More Tutorials About Charts and Animation


Enhance the Way a Web Page Loads With CSS Animations

George Martsoukos11 Dec 2018

How to Build a Static Portfolio Page With CSS & JavaScript

George Martsoukos02 Apr 2019

Build a Static Portfolio With Advanced CSS Bar Chart

George Martsoukos11 Jun 2019

How to Build a Semi-Circle Donut Chart With CSS

George Martsoukos25 Aug 2016



