Charts have been done in CSS many times before — there's even a dedicated project. So why this post? Because CSS evolves all the time — new, cool techniques emerge, allowing us to do things simpler, or add even more complicated new features.
Let's dive in!
Markup
Like the CSS Charts-project, we'll use a <table>
for our charts. It's accessible, simple and readable:
<table>
<caption>Monthly Sales Figures ($)</caption>
<tbody>
<tr>
<th scope="row">January</th>
<td data-value="28500">$28,500</td>
</tr>
<tr>
<th scope="row">February</th>
<td data-value="22300">$22,300</td>
</tr>
<tr>
<th scope="row">March</th>
<td data-value="30100">$30,100</td>
</tr>
<!-- etc. -->
</tbody>
</table>
Notice the data-value
-attribute? We'll grab that with the new, typed attr()
in CSS:
--_v: attr(data-value type(<number>), 0);
Before we continue, let's wrap the <table>
in a custom element, allowing us to add a few custom attributes, as well as functioning as a container, we can use to make responsive charts (more on that later!):
<data-chart
aria-label="Annual Revenue Growth"
max="50000"
min="0"
role="figure">
<table> ... </table>
</data-chart>
We'll grab the min
and max
attributes with attr()
as well, setting fallbacks to 0
and 100
:
--_min: attr(min type(<number>), 0);
--_max: attr(max type(<number>), 100);
Next, we'll add a simple grid to the <table>
, setting fixed heights for the caption and the label-area, and 1fr
for the chart itself:
A bit of math
The first chart we'll build is a Column Chart. For that, we need to calculate the height of each column:
td {
height: calc(
((var(--_v) - var(--_min)) /
(var(--_max) - var(--_min)))
* 100cqb - var(--data-chart-label-h)
);
}
So what's going on? We calculate the height based on min
and max
and the current value, then multiply by 100cqb
, which is the height of the "container" — finally, we deduct the height of the labels.
We'll skip the logic for colors (see final demo) — let's just add the <tbody>
-part and see what we have so far:
Cool! Now, let's add some grid lines and numeric labels along the y-axis. Because it's a <table>
, we can't just add any tag. Since we're not using <thead>
, let's use that:
<thead aria-hidden="true">
<tr>
<th colspan="2">50000</th>
</tr>
<tr>
<th colspan="2"></th>
</tr>
<tr>
<th colspan="2">40000</th>
</tr>
<!-- etc. -->
</thead>
We need aria-hidden
, so it's not picked up by screen readers.
This is styled like this:
Let's see how <thead>
and <tbody>
look together without Dev Tool's grid inspector:
Nice! Now, how about we create some variants?
Variants
The variants will use the exact same markup, but because of some limitations, we have to add a few extra attributes (for now).
Area Chart
For an Area Chart, we can re-use most of the Column Chart; we just need the value of the previous element for each table cell. Unfortunately, we can't grab the previous value with:
:nth-of-type(calc(sibling-index() - 1))
We could create a bunch of :has(tr:nth-of-type(x))
-selectors to grab the value of the previous cell, but that would make the code a bit messy.
So — for now — we set the previous value on each <td>
as data-prev
:
<tr>
<th scope="row">January</th>
<td data-value="28500" data-prev="28500">$28,500</td>
</tr>
Next, we create a simple polygon clip-path
that creates a diagonal line from the previous value to the current value:
clip-path: polygon(-1% 100%,
-1% calc(var(--_py) * 100%),
101% calc(var(--_y) * 100%),
101% 100%);
Let's not dive too deep into this, it's simply y-coordinates, calculated from value and previous value.
OK, this is one of the cases where a single color looks better. Let's update --data-chart-bar-bg
:
Line Chart
A line chart is almost the same as an area chart. We simply need to cut off the bottom part as well. That distance is the height of the line, which we define in --line-chart-line-h
:
clip-path: polygon(-1% calc(var(--_py) * 100% + var(--line-chart-line-h) / 2),
-1% calc(var(--_py) * 100% - var(--line-chart-line-h) / 2),
101% calc(var(--_y) * 100% - var(--line-chart-line-h) / 2),
101% calc(var(--_y) * 100% + var(--line-chart-line-h) / 2));
Now we get:
Bar Chart
The Bar Chart does not need the data-prev
-attribute, but it does require a few style-changes. Let's not dive deep into these changes — instead, inspect the styles in the final demo.
Pie Chart
The Pie Chart is a bit different! Here, we need to use conic-gradient
s, where each gradient needs to start where the previous one ends. Thus, we need an accumulated value (--_av
) and a total of all values (--_t
):
tbody {
--_t: attr(data-t type(<number>), 0);
}
td {
--_av: attr(data-av type(<number>), 0);
--_start: calc(
(var(--_av) / var(--_t)) *
360deg
);
--_end: calc(
(var(--_v) / var(--_t)) *
360deg
);
background: conic-gradient(
from var(--_start),
var(--_bg) 0 var(--_end),
#0000 var(--_end)
calc(var(--_start) + 360deg)
);
}
This gives us:
I'd really like it if we could use CSS counters to do the accumulated value and total, but couldn't get it to work (maybe someone like Temani Afif can!?).
Donut Chart
A donut chart is the same as a pie chart, but with a "cut out", for which we use a simple CSS Mask:
tbody {
mask: radial-gradient(circle, #0000 40%, #000 40%);
}
... and voilà, we get:
Grouped Charts
Grouped charts are simply done with multiple cells per table row:
<tr>
<th scope="row">January</th>
<td data-value="12500">12500</td>
<td data-value="9800">9800</td>
<td data-value="6200">6200</td>
</tr>
In the styles we then group them — and in some cases, present them like individual "layers". Inspect the styles in the final demo for more details — here's how they look:
Grouped Column Chart
Grouped Area Chart
Grouped Line Chart
Grouped Bar Chart
Grouped Donut Chart
Responsive Charts
One cool thing about controlling charts in CSS, is how easily we can make them responsive. First, let's add two new attributes to our <data-chart>
-component:
<data-chart
items-sm="7"
items-xs="4"
>
These indicate how many columns we want to show per breakpoint, in this case "sm" (small) and "xs" (x-small). Add as many as you want, and control the breakpoint in a @container
-query:
@container (max-width: 400px) {
&:is([type="area"], [type="column"], [type="line"]) {
&[items-xs="1"] tbody tr:nth-of-type(n+2),
&[items-xs="2"] tbody tr:nth-of-type(n+3) {
display: none;
}
}
}
So — in this case — if items-sm="1"
, hide all items after n+2
etc. Not rocket science, and hopefully something we can make more beautiful in the future.
Let's resize the screen and check it out. First, "sm":
Next, "xs":
Demos
I've created a bunch of demos, all forked from the first, as they all share the same CSS.
The first is shown below. Go to this collection to see them all.
NOTE: As the responsiveness is controlled by the size of the container, you'll most likely see the "sm"-version of the charts. Open CodePen in full screen to play around and resize. Also, note that Safari and Firefox do not support typed
attr()
, and thus the respnsiveness only works with a JS fallback.
Top comments (9)
Really cool how you’re taking advantage of the latest CSS features for charts, especially keeping things accessible and responsive. Which chart type do you find most challenging to get just right in practice?
Thank you! The charts themselves were not tricky, but I would have liked to be able to use CSS to get the previous values in the area/line charts!
The GOAT :-)
Thank you! 🐐
this is awesome but can you do a realtime candlestick chart?
Very nice! Is there a way to get the values into the Pie and Donut Graph?
For these you need the accumulated and total, as mentioned in the article. I’ll publish a web component soon, so you just feed it with JSON.
There is a <pie-chart> Web Component: pie-meister.github.io/
I’ll make
<data-chart>
, where you can set atype
-attribute to either: area, bar, column, donut, line or pieSome comments may only be visible to logged-in visitors. Sign in to view all comments.