Line charts
Area charts
This chart is based on CSS custom properties, grids and clip-path
— the latter being the most important.
-
Scales are set on the table:
- --y defines the y-axis scale, used to indicate the scale in the background but also to place the points on the curve;
- --x is the x-axis scale, simply expressed as the number of columns to display;
- --unit defines unit to be displayed in simulated tooltip (see below).
-
Each line
<tr>
in<tbody>
carries a set of custom properties, corresponding to all the values it contains. In a::before
pseudo-element, a position is defined for each value within theclip-path
polygon()
function.-
Since this function accepts two percentage values at each point,
the method is pretty straightforward.
The x-axis position is the number of columns (i.e., the offset from the left) and
the y-axis position is the ratio of the value on the scale, formulated as follows:
calc( ( 100% / var(--x) * 1 ) + var(--offset) ) calc( 100% - ( var(--1) / var(--y) * 100% ) )
, where * 1 and var(--1) is the index of the value as a whole, and var(--offset) is the value of half a column, to place the point in the middle of its column. - As you may have understood, the main pitfall of this graph is that it requires to know the number of points in advance.
-
Since
clip-path
still requires-webkit-
vendor prefix for Safari, we're using a custom property to preventpolygon()
duplication — based on a trick shared by Michelle Barker in 7 uses for CSS custom properties.
-
Since this function accepts two percentage values at each point,
the method is pretty straightforward.
The x-axis position is the number of columns (i.e., the offset from the left) and
the y-axis position is the ratio of the value on the scale, formulated as follows:
-
Each cell
<td>
in<tbody>
carries an::after
pseudo-element used to summarize its headers and value in a simulated tooltip, and a::before
pseudo-element to manage an interactive layer on the cell:-
multiline display is based on a
Lea Verou's trick using
white-space: pre;
and the\A
character; -
the display of custom properties in a pseudo-element is not so trivial:
content
only accepts strings, and our custom properties contains… number. Once again we recycle a Cassie Evans's trick based oncounter-reset
and its default value to convert our --value custom property into a string. Howevercounter-reset
requires an integer, and we may have a floating number. We first need to make sure we're using an integer, so we rely on Carter Li's ruse to usecalc()
to wrap our value— and@property
for Chromium-based browsers. Boum! - Finally --unit custom property polishes tooltip's content.
-
multiline display is based on a
Lea Verou's trick using
-
Everything else is just decoration::
-
a big
padding-top
on the table is used to reserve space for the charts — caution: it is necessary to applyborder-collapse: separate;
on the table for thepadding
to have an impact; - each line's
::before
is stretched in order to occupy all the reserved space; - a gradient background to represent the full area of the same
::before
; -
a
repeating-linear-gradient()
to represent the vertical scale in the table's background; -
and hover interactions to highlight the hovered value:
its column using a pseudo-element — positioned with the help of clever calculations —
and
mix-blend-mode
for a wow effect.
-
a big
Switch
Allows you to disable styles on the following table.
Year | Jan. | Feb. | Mar. | Apr. | May | June | July | Aug. | Sep. | Oct. | Nov. | Dec. |
---|---|---|---|---|---|---|---|---|---|---|---|---|
2017 | 8 °C | 6 °C | 9 °C | 12 °C | 15 °C | 21 °C | 24 °C | 25 °C | 22 °C | 19 °C | 14 °C | 9 °C |
HTML
<table class="chaarts line" style="--y: 32; --x: 13; --t-1: 'Jan.'; --t-2: 'Feb.'; […]">
<caption id="caption-3">[…]</caption>
<thead>
<tr>
<th scope="col">[…]</th>
<th scope="col">Jan.</th>
<th scope="col">[…]</th>
</tr>
</thead>
<tbody>
<tr style="--year: '2017'; --1: 8; --2: 6; --3: 9; --4: 12; --5: 15; --6: 21; --7: 24; --8: 25; --9: 22; --10: 19; --11: 14; --12: 9;">
<th scope="row">2017</th>
<td>8 °C</td>
<td>[…]</td>
</tr>
</tbody>
</table>
css
@charset "UTF-8";
.chaarts.line {
--offset: calc((100% / var(--x)) / 2);
--height: calc(32em - 2rem);
--bottom: calc(100% - var(--height));
padding: var(--height) 0 1rem;
position: relative;
transition: background 0.3s var(--move), color 0.3s var(--move);
}
.chaarts.line::after {
--scale: calc((100% - (var(--y) * 1px)) / var(--y));
background: repeating-linear-gradient(to bottom, var(--scrollable-background) 0 var(--scale), var(--foreground-o-25) calc(var(--scale) + 1px));
content: "";
inline-size: 100%;
inset-block-end: var(--bottom);
inset-block-start: 0;
position: absolute;
z-index: 1;
}
.chaarts.line tr::before {
content: "";
position: absolute;
}
.chaarts.line [scope=row],
.chaarts.line thead th:first-child {
color: var(--color, currentcolor);
text-align: start;
}
.chaarts.line [style]::before {
background: linear-gradient(to top, var(--chaarts-blue), var(--chaarts-red) 75%);
clip-path: var(--polygon);
content: "";
inline-size: 100%;
inset-block-end: var(--bottom);
inset-block-start: 0;
position: absolute;
scale: var(--is-rtl, 1) 1;
z-index: 2;
--polygon: polygon(
0% 100%,
calc((100% / var(--x) * 1)) 100%,
calc((100% / var(--x) * 1)) calc(100% - (var(--1) / var(--y) * 100%)),
calc((100% / var(--x) * 1) + var(--offset)) calc(100% - (var(--1) / var(--y) * 100%)),
calc((100% / var(--x) * 2) + var(--offset)) calc(100% - (var(--2) / var(--y) * 100%)),
calc((100% / var(--x) * 3) + var(--offset)) calc(100% - (var(--3) / var(--y) * 100%)),
calc((100% / var(--x) * 4) + var(--offset)) calc(100% - (var(--4) / var(--y) * 100%)),
calc((100% / var(--x) * 5) + var(--offset)) calc(100% - (var(--5) / var(--y) * 100%)),
calc((100% / var(--x) * 6) + var(--offset)) calc(100% - (var(--6) / var(--y) * 100%)),
calc((100% / var(--x) * 7) + var(--offset)) calc(100% - (var(--7) / var(--y) * 100%)),
calc((100% / var(--x) * 8) + var(--offset)) calc(100% - (var(--8) / var(--y) * 100%)),
calc((100% / var(--x) * 9) + var(--offset)) calc(100% - (var(--9) / var(--y) * 100%)),
calc((100% / var(--x) * 10) + var(--offset)) calc(100% - (var(--10) / var(--y) * 100%)),
calc((100% / var(--x) * 11) + var(--offset)) calc(100% - (var(--11) / var(--y) * 100%)),
calc((100% / var(--x) * 12) + var(--offset)) calc(100% - (var(--12) / var(--y) * 100%)),
100% calc(100% - (var(--12) / var(--y) * 100%)),
100% 100%,
0% 100%
);
}
@media (prefers-contrast: more) {
.chaarts.line [style]::before {
background: var(--chaarts-blue);
}
}
.chaarts.line th,
.chaarts.line td {
background: var(--background-lighter);
font-weight: bold;
inline-size: calc(100% / var(--x));
text-align: center;
}
.chaarts.line [scope=col]:not(:first-child)::after {
background: var(--background-lighter) var(--stripes);
background-blend-mode: exclusion;
block-size: calc(100% - 4rem);
content: "";
inline-size: inherit;
inset-block-end: 4rem;
inset-inline-start: calc(100% / var(--x) * var(--index));
mix-blend-mode: soft-light;
opacity: 0;
position: absolute;
transition: opacity 0.3s var(--move);
z-index: 3;
}
html:where([data-theme=dark]) .chaarts.line [scope=col]:not(:first-child)::after {
mix-blend-mode: lighten;
}
@media (prefers-color-scheme: dark) {
.no-js .chaarts.line [scope=col]:not(:first-child)::after, html:where(:not([data-theme=light])) .chaarts.line [scope=col]:not(:first-child)::after {
mix-blend-mode: lighten;
}
}
.chaarts.line [scope=col]:nth-child(2)::after {
--index: 1;
}
.chaarts.line [scope=col]:nth-child(3)::after {
--index: 2;
}
.chaarts.line [scope=col]:nth-child(4)::after {
--index: 3;
}
.chaarts.line [scope=col]:nth-child(5)::after {
--index: 4;
}
.chaarts.line [scope=col]:nth-child(6)::after {
--index: 5;
}
.chaarts.line [scope=col]:nth-child(7)::after {
--index: 6;
}
.chaarts.line [scope=col]:nth-child(8)::after {
--index: 7;
}
.chaarts.line [scope=col]:nth-child(9)::after {
--index: 8;
}
.chaarts.line [scope=col]:nth-child(10)::after {
--index: 9;
}
.chaarts.line [scope=col]:nth-child(11)::after {
--index: 10;
}
.chaarts.line [scope=col]:nth-child(12)::after {
--index: 11;
}
.chaarts.line [scope=col]:nth-child(13)::after {
--index: 12;
}
.chaarts.line [scope=col]:hover::after {
opacity: 0.75;
}
.chaarts.line td {
--value: var(--1);
--term: var(--t-1);
line-height: 1.5;
}
.chaarts.line td::before {
block-size: 1.5rem;
content: "";
inline-size: inherit;
position: absolute;
translate: calc(-50% * var(--is-rtl, 1)) 0;
z-index: 10;
}
.chaarts.line td::after {
--arrow: calc(100% - 0.25rem);
--top: calc(var(--height) - (var(--value) / var(--y) * var(--height)));
--polygon: polygon(
0% 0%,
100% 0%,
100% var(--arrow),
calc(50% - 0.25rem) var(--arrow),
50% 100%,
calc(50% + 0.25rem) var(--arrow),
0% var(--arrow)
);
--integer: calc(var(--value));
background-color: var(--foreground-lighter);
clip-path: var(--polygon);
color: var(--background-lighter);
content: var(--term) " " var(--year) "\a" counter(value) " " var(--unit);
counter-reset: value var(--integer);
inset-block-start: var(--top, 0);
inset-inline-start: calc(var(--offset) * 3);
opacity: 0;
padding: 0.5rem;
pointer-events: none;
position: absolute;
transform: translate3d(var(--rtl-offset, -50%), -125%, 0) perspective(1000px) rotate3d(1, 0, 0, 45deg);
transform-origin: 50% calc(100% + 10px);
transition: opacity 0.2s var(--enter), transform 0.2s var(--enter);
white-space: pre;
z-index: 5;
}
[dir=rtl] .chaarts.line td:first-of-type::after {
inset-inline-start: var(--offset);
}
.chaarts.line td + td::after {
inset-inline-start: calc(100% / var(--x) * var(--index) + var(--offset));
}
.chaarts.line td:nth-child(2)::after {
--value: var(--1);
--term: var(--t-1);
--index: 1;
}
[dir=rtl] .chaarts.line td:nth-child(2)::after {
--index: 0;
--rtl-offset: -40%;
}
.chaarts.line td:nth-child(3)::after {
--value: var(--2);
--term: var(--t-2);
--index: 2;
}
[dir=rtl] .chaarts.line td:nth-child(3)::after {
--index: 1;
--rtl-offset: -40%;
}
.chaarts.line td:nth-child(4)::after {
--value: var(--3);
--term: var(--t-3);
--index: 3;
}
[dir=rtl] .chaarts.line td:nth-child(4)::after {
--index: 2;
--rtl-offset: -40%;
}
.chaarts.line td:nth-child(5)::after {
--value: var(--4);
--term: var(--t-4);
--index: 4;
}
[dir=rtl] .chaarts.line td:nth-child(5)::after {
--index: 3;
--rtl-offset: -40%;
}
.chaarts.line td:nth-child(6)::after {
--value: var(--5);
--term: var(--t-5);
--index: 5;
}
[dir=rtl] .chaarts.line td:nth-child(6)::after {
--index: 4;
--rtl-offset: -40%;
}
.chaarts.line td:nth-child(7)::after {
--value: var(--6);
--term: var(--t-6);
--index: 6;
}
[dir=rtl] .chaarts.line td:nth-child(7)::after {
--index: 5;
--rtl-offset: -40%;
}
.chaarts.line td:nth-child(8)::after {
--value: var(--7);
--term: var(--t-7);
--index: 7;
}
[dir=rtl] .chaarts.line td:nth-child(8)::after {
--index: 6;
--rtl-offset: -40%;
}
.chaarts.line td:nth-child(9)::after {
--value: var(--8);
--term: var(--t-8);
--index: 8;
}
[dir=rtl] .chaarts.line td:nth-child(9)::after {
--index: 7;
--rtl-offset: -40%;
}
.chaarts.line td:nth-child(10)::after {
--value: var(--9);
--term: var(--t-9);
--index: 9;
}
[dir=rtl] .chaarts.line td:nth-child(10)::after {
--index: 8;
--rtl-offset: -40%;
}
.chaarts.line td:nth-child(11)::after {
--value: var(--10);
--term: var(--t-10);
--index: 10;
}
[dir=rtl] .chaarts.line td:nth-child(11)::after {
--index: 9;
--rtl-offset: -40%;
}
.chaarts.line td:nth-child(12)::after {
--value: var(--11);
--term: var(--t-11);
--index: 11;
}
[dir=rtl] .chaarts.line td:nth-child(12)::after {
--index: 10;
--rtl-offset: -40%;
}
.chaarts.line td:nth-child(13)::after {
--value: var(--12);
--term: var(--t-12);
--index: 12;
}
[dir=rtl] .chaarts.line td:nth-child(13)::after {
--index: 11;
--rtl-offset: -40%;
}
.chaarts.line td:hover::after {
opacity: 1;
transform: translate3d(var(--rtl-offset, -50%), -125%, 0) perspective(1000px) rotate3d(1, 0, 0, 0deg);
transition: opacity 0.2s var(--exit), transform 0.2s var(--exit);
}
The calculation twist
The polygon()
path
To begin with, you need to understand that clip-path
is a path,
just like a vector shape. It must therefore be closed.
So the path starts at 0% 100% — bottom left, does its path life,
toggles to 100% 100% and comes back looping at 0% 100%.
And in its path, each point must be positioned in abscissa and ordinate.
The X-axis position
The first position is simple: divide 100%
by the
var(--x) scale, and multiply by the index of the element.
For example: calc( ( 100% / var(--x) * 1) )
.
To place each point in the middle of its column, we shift it by half a column
— which we do by adding to the previous calculation var(--offset),
which corresponds to calc( ( 100% / var(--x) ) / 2 )
.
The final position is therefore, here for the third point:
calc( ( 100% / var(--x) * 3) + var(--offset) )
.
The Y-axis position
In this graph, the Y-axis is the most important axis. So to place the point,
we start by calculating the ratio of its value on the scale
— formulated as follows: var(--1) / var(--y)
. And because
polygon()
uses percentage values, we report this calculation on 100%:
( var(--1) / var(--y) * 100% )
.
And finally, since the polygon's datums start from the top left, the position
position must be defined according to the top of the box.
The final formula then looks like this — again for the third element:
calc( 100% - ( var(--3) / var(--y) * 100% ) )
.
Line chart with dots
In the end, this variant differs little from the previous version:
-
the
polygon()
is continued to form a line, duplicating each point with an offset of 4px — the line thickness — and in the reverse order; -
the
::before
pseudo-element that displays the tooltip takes here the form of a point on the curve — positioned using the same calculations that are used in the polygon; -
and especially, since
clip-path
is applied in the line<tr>
: you can put more than one ! So we need to add a combination of color and pattern to distinguish each line and associate them visually with their caption.
Switch
Allows you to disable styles on the following table.
Year | Jan. | Feb. | Mar. | Apr. | May | June | July | Aug. | Sep. | Oct. | Nov. | Dec. |
---|---|---|---|---|---|---|---|---|---|---|---|---|
2017 | 8 °C | 6 °C | 9 °C | 12 °C | 15 °C | 21 °C | 24 °C | 25 °C | 22 °C | 19 °C | 14 °C | 9 °C |
2018 | 10 °C | 4 °C | 7 °C | 13 °C | 17 °C | 20 °C | 22 °C | 23 °C | 26 °C | 17 °C | 14 °C | 10 °C |
css
.chaarts.points [style]::before {
background: var(--color, currentcolor) var(--background);
scale: var(--is-rtl, 1) 1;
transition: opacity 0.3s var(--move);
--polygon: polygon(
calc((100% / var(--x) * 1) + var(--offset)) calc(100% - (var(--1) / var(--y) * 100%)),
calc((100% / var(--x) * 2) + var(--offset)) calc(100% - (var(--2) / var(--y) * 100%)),
calc((100% / var(--x) * 3) + var(--offset)) calc(100% - (var(--3) / var(--y) * 100%)),
calc((100% / var(--x) * 4) + var(--offset)) calc(100% - (var(--4) / var(--y) * 100%)),
calc((100% / var(--x) * 5) + var(--offset)) calc(100% - (var(--5) / var(--y) * 100%)),
calc((100% / var(--x) * 6) + var(--offset)) calc(100% - (var(--6) / var(--y) * 100%)),
calc((100% / var(--x) * 7) + var(--offset)) calc(100% - (var(--7) / var(--y) * 100%)),
calc((100% / var(--x) * 8) + var(--offset)) calc(100% - (var(--8) / var(--y) * 100%)),
calc((100% / var(--x) * 9) + var(--offset)) calc(100% - (var(--9) / var(--y) * 100%)),
calc((100% / var(--x) * 10) + var(--offset)) calc(100% - (var(--10) / var(--y) * 100%)),
calc((100% / var(--x) * 11) + var(--offset)) calc(100% - (var(--11) / var(--y) * 100%)),
calc((100% / var(--x) * 12) + var(--offset)) calc(100% - (var(--12) / var(--y) * 100%)),
calc((100% / var(--x) * 13) + var(--offset)) calc(100% - (var(--12) / var(--y) * 100%)),
100% calc(100% - (var(--12) / var(--y) * 100%)),
100% calc((100% + 0.25rem) - (var(--12) / var(--y) * 100%)),
calc((100% / var(--x) * 13) + var(--offset)) calc((100% + 0.25rem) - (var(--12) / var(--y) * 100%)),
calc((100% / var(--x) * 12) + var(--offset)) calc((100% + 0.25rem) - (var(--12) / var(--y) * 100%)),
calc((100% / var(--x) * 11) + var(--offset)) calc((100% + 0.25rem) - (var(--11) / var(--y) * 100%)),
calc((100% / var(--x) * 10) + var(--offset)) calc((100% + 0.25rem) - (var(--10) / var(--y) * 100%)),
calc((100% / var(--x) * 9) + var(--offset)) calc((100% + 0.25rem) - (var(--9) / var(--y) * 100%)),
calc((100% / var(--x) * 8) + var(--offset)) calc((100% + 0.25rem) - (var(--8) / var(--y) * 100%)),
calc((100% / var(--x) * 7) + var(--offset)) calc((100% + 0.25rem) - (var(--7) / var(--y) * 100%)),
calc((100% / var(--x) * 6) + var(--offset)) calc((100% + 0.25rem) - (var(--6) / var(--y) * 100%)),
calc((100% / var(--x) * 5) + var(--offset)) calc((100% + 0.25rem) - (var(--5) / var(--y) * 100%)),
calc((100% / var(--x) * 4) + var(--offset)) calc((100% + 0.25rem) - (var(--4) / var(--y) * 100%)),
calc((100% / var(--x) * 3) + var(--offset)) calc((100% + 0.25rem) - (var(--3) / var(--y) * 100%)),
calc((100% / var(--x) * 2) + var(--offset)) calc((100% + 0.25rem) - (var(--2) / var(--y) * 100%)),
calc((100% / var(--x) * 1) + var(--offset)) calc((100% + 0.25rem) - (var(--1) / var(--y) * 100%))
);
}
.chaarts.points [style] th::before {
background: var(--color, currentcolor) var(--background);
block-size: 1rem;
content: "";
display: inline-block;
inline-size: 1rem;
translate: -0.2rem 0.1rem 0;
}
.chaarts.points [style] td::before {
--size: 1rem;
--top: calc(var(--height) - (var(--value) / var(--y) * var(--height)));
background: var(--color, currentcolor) var(--background);
block-size: var(--size);
border: 2px solid var(--background-lighter);
border-radius: 50%;
box-shadow: 0 0 0.25rem var(--foreground-o-50);
content: "";
inline-size: var(--size);
inset-block-start: var(--top, 100%);
inset-inline-start: calc(var(--offset) * 3);
position: absolute;
transition: opacity 0.3s var(--move), transform 0.3s var(--move);
translate: calc(var(--size) / -2) calc(var(--size) / -2) 0;
z-index: 4;
}
.chaarts.points [style] td + td::before {
inset-inline-start: calc(100% / var(--x) * var(--index) + var(--offset));
}
[dir=rtl] .chaarts.points [style] td::before, [dir=rtl] .chaarts.points [style] td + td::before {
inset-inline-start: unset;
}
.chaarts.points [style] td:nth-of-type(2)::before {
--value: var(--2);
--index: 2;
}
.chaarts.points [style] td:nth-of-type(3)::before {
--value: var(--3);
--index: 3;
}
.chaarts.points [style] td:nth-of-type(4)::before {
--value: var(--4);
--index: 4;
}
.chaarts.points [style] td:nth-of-type(5)::before {
--value: var(--5);
--index: 5;
}
.chaarts.points [style] td:nth-of-type(6)::before {
--value: var(--6);
--index: 6;
}
.chaarts.points [style] td:nth-of-type(7)::before {
--value: var(--7);
--index: 7;
}
.chaarts.points [style] td:nth-of-type(8)::before {
--value: var(--8);
--index: 8;
}
.chaarts.points [style] td:nth-of-type(9)::before {
--value: var(--9);
--index: 9;
}
.chaarts.points [style] td:nth-of-type(10)::before {
--value: var(--10);
--index: 10;
}
.chaarts.points [style] td:nth-of-type(11)::before {
--value: var(--11);
--index: 11;
}
.chaarts.points [style] td:nth-of-type(12)::before {
--value: var(--12);
--index: 12;
}
.chaarts.points [style]:nth-of-type(1n + 1) {
--background: var(--checkers);
}
.chaarts.points [style]:nth-of-type(2n + 2) {
--background: var(--hexagons);
}
.chaarts.points [style]:nth-of-type(3n + 3) {
--background: var(--triangles);
}
.chaarts.points [style]:nth-of-type(4n + 4) {
--background: var(--zig);
}
.chaarts.points [style]:nth-of-type(5n + 5) {
--background: var(--stripes);
}
.chaarts.points [style]:nth-of-type(6n + 6) {
--background: var(--dots);
}
.chaarts.points tbody:hover [style]::before,
.chaarts.points tbody:hover [style] td::before {
opacity: 0.25;
}
.chaarts.points tbody:hover [style]:hover::before,
.chaarts.points tbody:hover [style]:hover td::before {
opacity: 1;
}
.chaarts.points tbody:hover [style]:hover td::before {
scale: 1.25;
translate: calc(var(--size) / -2) calc(var(--size) / -2) 0;
}
.chaarts.points [scope=col]:not(:first-child)::after {
mix-blend-mode: multiply;
}
.chaarts.points [scope=col]:not(:first-child):hover::after {
opacity: 0.5;
}
Note
To play more and familiarize yourself with clip-path
,
Bennett Feely created
clippy.