Line charts

Area charts

This chart is based on CSS custom properties, grids and clip-path — the latter being the most important.

  1. 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).
  2. 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 the clip-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 prevent polygon() duplication — based on a trick shared by Michelle Barker in 7 uses for CSS custom properties.
  3. 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:
  4. 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 apply border-collapse: separate; on the table for the padding 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.

Switch
Allows you to disable styles on the following table.

Average monthly temperature in 2017
Year Jan. Feb. Mar. Apr. May June July Aug. Sep. Oct. Nov. Dec.
2017 °C °C °C 12 °C 15 °C 21 °C 24 °C 25 °C 22 °C 19 °C 14 °C °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:

  1. the polygon() is continued to form a line, duplicating each point with an offset of 4px — the line thickness — and in the reverse order;
  2. 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;
  3. 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.

Average monthly temperature per year
Year Jan. Feb. Mar. Apr. May June July Aug. Sep. Oct. Nov. Dec.
2017 °C °C °C 12 °C 15 °C 21 °C 24 °C 25 °C 22 °C 19 °C 14 °C °C
2018 10 °C °C °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.