DEV Community

Cover image for Charts in CSS
Mads Stoumann
Mads Stoumann

Posted on

Charts in CSS

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>
Enter fullscreen mode Exit fullscreen mode

Notice the data-value-attribute? We'll grab that with the new, typed attr() in CSS:

--_v: attr(data-value type(<number>), 0);
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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:

Simple Grid

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)
  );
}
Enter fullscreen mode Exit fullscreen mode

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:

Table Data Added

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>
Enter fullscreen mode Exit fullscreen mode

We need aria-hidden, so it's not picked up by screen readers.

This is styled like this:

Thead Added

Let's see how <thead> and <tbody> look together without Dev Tool's grid inspector:

Final Column Chart

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))
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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%);
Enter fullscreen mode Exit fullscreen mode

Let's not dive too deep into this, it's simply y-coordinates, calculated from value and previous value.

Area Chart

OK, this is one of the cases where a single color looks better. Let's update --data-chart-bar-bg:

Area Chart Single Color

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));
Enter fullscreen mode Exit fullscreen mode

Now we get:

Line Chart

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.

Bar Chart

Pie Chart

The Pie Chart is a bit different! Here, we need to use conic-gradients, 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)
  );
}
Enter fullscreen mode Exit fullscreen mode

This gives us:

Pie Chart

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%);
}
Enter fullscreen mode Exit fullscreen mode

... and voilà, we get:

Donut Chart

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>
Enter fullscreen mode Exit fullscreen mode

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

Multi Column Column Chart

Grouped Area Chart

Multi Column Area Chart

Grouped Line Chart

Multi Column Line Chart

Grouped Bar Chart

Multi Column Chart

Grouped Donut Chart

Multi Column 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"
>
Enter fullscreen mode Exit fullscreen mode

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;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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":

Small

Next, "xs":

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)

Collapse
 
dotallio profile image
Dotallio

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?

Collapse
 
madsstoumann profile image
Mads Stoumann

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!

Collapse
 
artydev profile image
artydev

The GOAT :-)

Collapse
 
madsstoumann profile image
Mads Stoumann • Edited

Thank you! 🐐

Collapse
 
chovy profile image
chovy

this is awesome but can you do a realtime candlestick chart?

Collapse
 
adilbo profile image
adilbo

Very nice! Is there a way to get the values into the Pie and Donut Graph?

Collapse
 
madsstoumann profile image
Mads Stoumann

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.

Collapse
 
dannyengelman profile image
Danny Engelman • Edited

There is a <pie-chart> Web Component: pie-meister.github.io/

Thread Thread
 
madsstoumann profile image
Mads Stoumann • Edited

I’ll make <data-chart>, where you can set a type-attribute to either: area, bar, column, donut, line or pie

Some comments may only be visible to logged-in visitors. Sign in to view all comments.