CSS Charts: 16 Examples You Can Build Without a JS Library

Bars, slices and rings can all be drawn with HTML and CSS alone, with no JavaScript charting library. The catch: the drawing carries no text, so the numbers must also exist as real text in the document.

Below are 16 CSS charts you can try and copy, followed by a guide to building, animating and testing them.

By Bogdan Sandu · Updated October 28, 2026

Bar and column charts

Grid and flexbox charts where every bar reads its length from a custom property. Each one keeps the numbers in a table or a list, so the drawing is only the picture of data that already exists as text.

Column chart with a table view

  • grid-auto-flow: column
  • calc() heights
  • aria-pressed
Orders this week Fernhill Roasters online shop, 522 orders
Orders per day this week
DayOrders
Monday42
Tuesday58
Wednesday51
Thursday66
Friday88
Saturday120
Sunday97

Press Table view to swap the chart for its data.

Each column is a grid item whose height is its --ch-v divided by the --ch-max set on the plot, so changing the scale means changing one number. The table is always in the accessibility tree; the Table view button only makes it visible for sighted readers who want the exact figures.

View code
.ch-col {
  position: relative;
  display: grid;
  gap: 16px;
  width: min(100%, 560px);
  margin: 0;
  padding: 22px 22px 20px;
  border-radius: 18px;
  background: #fbf7f1;
  color: #2b1d14;
  box-shadow: 0 1px 0 #e9dfd2, 0 18px 40px rgba(60, 35, 15, .08);
}
.ch-col__head {
  display: grid;
  gap: 2px;
}
.ch-col__title {
  font: 600 19px/1.3 "Fraunces", Georgia, serif;
  letter-spacing: 0;
}
.ch-col__sub {
  font-size: 13px;
  color: #6d5a4b;
}
.ch-col__plot {
  position: relative;
  height: 230px;
  padding: 18px 0 0 34px;
}
.ch-col__grid {
  position: absolute;
  inset: 18px 0 26px 0;
}
.ch-col__grid span {
  position: absolute;
  left: 0;
  right: 0;
  bottom: calc(var(--ch-t) / 120 * 100%);
  height: 0;
  font-size: 11px;
  line-height: 0;
  color: #6d5a4b;
}
.ch-col__grid span::after {
  content: "";
  position: absolute;
  left: 34px;
  right: 0;
  top: 0;
  border-top: 1px dashed #dccdbb;
}
.ch-col__bars {
  position: relative;
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: minmax(0, 1fr);
  gap: 8px;
  height: 100%;
}
.ch-col__item {
  display: grid;
  grid-template-rows: minmax(0, 1fr) 26px;
  justify-items: center;
  min-width: 0;
}
.ch-col__bar {
  position: relative;
  align-self: end;
  width: min(100%, 44px);
  height: calc(var(--ch-v) / var(--ch-max) * 100%);
  border-radius: 6px 6px 2px 2px;
  background: #8a5a3b;
}
.ch-col__item--top .ch-col__bar {
  background: #4a2a17;
}
.ch-col__bar b {
  position: absolute;
  bottom: calc(100% + 4px);
  left: 50%;
  translate: -50% 0;
  font-size: 12px;
  font-weight: 700;
  line-height: 1;
  color: #2b1d14;
}
.ch-col__item i {
  align-self: center;
  font-size: 12px;
  font-style: normal;
  color: #6d5a4b;
}
.ch-col__table {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
.ch-col[data-view="table"] .ch-col__plot {
  display: none;
}
.ch-col[data-view="table"] .ch-col__table {
  position: static;
  width: 100%;
  height: auto;
  overflow: visible;
  clip-path: none;
  white-space: normal;
}
.ch-col__table table {
  width: 100%;
  border-collapse: collapse;
  font-size: 14px;
}
.ch-col__table caption {
  padding-bottom: 8px;
  text-align: left;
  font-weight: 600;
}
.ch-col__table th,
.ch-col__table td {
  padding: 6px 4px;
  border-bottom: 1px solid #e6dacb;
  text-align: left;
  font-weight: 400;
}
.ch-col__table thead th {
  font-weight: 600;
  color: #6d5a4b;
}
.ch-col__table td {
  text-align: right;
  font-variant-numeric: tabular-nums;
}
.ch-col__toggle {
  justify-self: start;
  min-height: 44px;
  padding: 0 18px;
  border: 1.5px solid #4a2a17;
  border-radius: 999px;
  background: transparent;
  color: #4a2a17;
  font: inherit;
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
  transition: background-color .2s, color .2s;
}
.ch-col__toggle[aria-pressed="true"] {
  background: #4a2a17;
  color: #fbf7f1;
}
.ch-col__toggle:focus-visible {
  outline: 3px solid #1847f0;
  outline-offset: 3px;
}

Horizontal bars drawn inside a table

  • real table
  • typed attr() in @supports
  • flex cells
Top help center searches Parcelly, last 30 days
Search topicSearches
Track a parcel
1,840
Change delivery address
1,215
Refund status
980
Missed delivery
760
Customs fees
410
Delete account
190

The table is the chart: labels are header cells, numbers are visible text, and the bar is a decorative span in the same cell. Where typed attr() works the bar reads its length from data-v; everywhere else the inline --ch-v takes over, so you can drop the style attribute once every engine ships typed attr().

View code
.ch-rank {
  width: min(100%, 540px);
  padding: 22px 22px 16px;
  border-radius: 16px;
  background: #fff;
  color: #1c1530;
  box-shadow: 0 1px 2px rgba(28, 21, 48, .06), 0 16px 36px rgba(28, 21, 48, .08);
}
.ch-rank__table {
  width: 100%;
  border-collapse: collapse;
  table-layout: fixed;
  font-size: 14px;
}
.ch-rank__cap {
  caption-side: top;
  padding-bottom: 12px;
  text-align: left;
}
.ch-rank__title {
  display: block;
  font-size: 17px;
  font-weight: 700;
  letter-spacing: -.01em;
}
.ch-rank__sub {
  display: block;
  font-size: 13px;
  color: #5d5570;
}
.ch-rank__table thead th {
  padding: 0;
}
.ch-rank__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
.ch-rank__table tbody th {
  width: 42%;
  padding: 9px 12px 9px 0;
  border-top: 1px solid #eeebf5;
  text-align: left;
  font-weight: 500;
  line-height: 1.3;
  vertical-align: middle;
}
.ch-rank__table td {
  padding: 9px 0;
  border-top: 1px solid #eeebf5;
  vertical-align: middle;
}
.ch-rank__cell {
  display: flex;
  align-items: center;
  gap: 8px;
}
.ch-rank__bar {
  flex: none;
  width: calc((100% - 52px) * var(--ch-v) / 100);
  min-width: 4px;
  height: 14px;
  border-radius: 7px;
  background: #5b3fd1;
}
@supports (width: calc(attr(data-v type(<number>), 0) * 1px)) {
  .ch-rank__bar[data-v] {
    width: calc((100% - 52px) * attr(data-v type(<number>), 0) / 100);
  }
}
.ch-rank__num {
  flex: none;
  font-size: 13px;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  color: #3b2f63;
}

Stacked 100% bars with patterns

  • flex-grow from data
  • pattern fills
  • WCAG 1.4.1
Sessions by device Share of all sessions per quarter, 2026
Share of sessions by device per quarter, 2026, in percent
QuarterMobileDesktopTablet
Q158366
Q261336
Q363325
Q466304

Tablet share: 6%, 6%, 5% and 4%.

Each segment uses its share as flex-grow, so the segments of a row always fill the track. The three series differ in lightness (every neighbour pair is above 3:1) and in texture: solid, hatched and dotted, so the chart still reads in grayscale or for color-blind readers.

View code
.ch-stack {
  position: relative;
  display: grid;
  gap: 14px;
  width: min(100%, 560px);
  margin: 0;
  padding: 22px;
  border-radius: 14px;
  background: #fff;
  color: #0f1f4d;
  box-shadow: 0 0 0 1px #e3e8f2, 0 14px 30px rgba(15, 31, 77, .07);
}
.ch-stack__head {
  display: grid;
  gap: 2px;
}
.ch-stack__title {
  font-size: 17px;
  font-weight: 700;
}
.ch-stack__sub,
.ch-stack__foot {
  margin: 0;
  font-size: 13px;
  color: #4a5675;
}
.ch-stack__legend {
  display: flex;
  flex-wrap: wrap;
  gap: 6px 18px;
  margin: 0;
  padding: 0;
  list-style: none;
  font-size: 13px;
}
.ch-stack__legend li {
  display: flex;
  align-items: center;
  gap: 7px;
}
.ch-stack__key {
  width: 18px;
  height: 18px;
  border-radius: 4px;
}
.ch-stack__rows {
  display: grid;
  gap: 10px;
}
.ch-stack__row {
  display: grid;
  grid-template-columns: 2rem minmax(0, 1fr);
  align-items: center;
  gap: 10px;
}
.ch-stack__q {
  font-size: 13px;
  font-weight: 600;
}
.ch-stack__bar {
  display: flex;
  gap: 2px;
  height: 34px;
  overflow: hidden;
  border-radius: 8px;
}
.ch-stack__seg {
  display: grid;
  place-items: center;
  flex: var(--ch-v) 1 0%;
  min-width: 0;
  font-size: 12px;
  font-weight: 700;
}
.ch-stack__seg--m {
  background: #1e40af;
  color: #fff;
}
.ch-stack__seg--d {
  background: repeating-linear-gradient(135deg, #93c5fd 0 6px, #bfdbfe 6px 9px);
  color: #0f1f4d;
}
.ch-stack__seg--t {
  background: radial-gradient(circle, #94a3b8 1.3px, #0000 1.6px) 0 0 / 6px 6px, #334155;
}
.ch-stack__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

Grouped columns, last year against this year

  • nested grid
  • hatched vs solid
  • direct labels
Trial signups per quarter Lumen Notes, 2025 compared with 2026
Trial signups per quarter, 2025 and 2026
Quarter20252026
Q1320410
Q2380520
Q3300470
Q4450610

Each quarter is a small two-column grid inside the main grid, and both bars share one --ch-max. Last year is a hatched outline, this year is solid teal, and every bar carries its own number, so the comparison never depends on telling two colors apart.

View code
.ch-grp {
  position: relative;
  display: grid;
  gap: 14px;
  width: min(100%, 560px);
  margin: 0;
  padding: 22px;
  border-radius: 20px;
  background: #fff;
  color: #10201e;
  box-shadow: 0 18px 40px rgba(16, 32, 30, .08);
}
.ch-grp__head {
  display: grid;
  gap: 2px;
}
.ch-grp__title {
  font-size: 17px;
  font-weight: 700;
}
.ch-grp__sub {
  font-size: 13px;
  color: #4b5b59;
}
.ch-grp__legend {
  display: flex;
  gap: 18px;
  margin: 0;
  padding: 0;
  list-style: none;
  font-size: 13px;
  font-weight: 600;
}
.ch-grp__legend li {
  display: flex;
  align-items: center;
  gap: 7px;
}
.ch-grp__key {
  width: 16px;
  height: 16px;
  border-radius: 4px;
}
.ch-grp__plot {
  position: relative;
  height: 220px;
  padding: 18px 0 0 32px;
}
.ch-grp__grid {
  position: absolute;
  inset: 18px 0 26px 0;
}
.ch-grp__grid span {
  position: absolute;
  left: 0;
  right: 0;
  bottom: calc(var(--ch-t) / 700 * 100%);
  height: 0;
  font-size: 11px;
  line-height: 0;
  color: #4b5b59;
}
.ch-grp__grid span::after {
  content: "";
  position: absolute;
  left: 32px;
  right: 0;
  top: 0;
  border-top: 1px solid #e7ecec;
}
.ch-grp__groups {
  position: relative;
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: clamp(8px, 4%, 24px);
  height: 100%;
}
.ch-grp__group {
  display: grid;
  grid-template-rows: minmax(0, 1fr) 26px;
  min-width: 0;
}
.ch-grp__pair {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  align-items: end;
  gap: 3px;
}
.ch-grp__bar {
  position: relative;
  height: calc(var(--ch-v) / var(--ch-max) * 100%);
  border-radius: 5px 5px 0 0;
}
.ch-grp__bar--a {
  background: repeating-linear-gradient(45deg, #fff 0 3px, #cbd5e1 3px 6px);
  box-shadow: inset 0 0 0 1.5px #475569;
}
.ch-grp__bar--b {
  background: #0f766e;
}
.ch-grp__bar b {
  position: absolute;
  bottom: calc(100% + 3px);
  left: 50%;
  translate: -50% 0;
  font-size: 11px;
  font-weight: 700;
  line-height: 1;
}
.ch-grp__group i {
  align-self: center;
  justify-self: center;
  font-size: 12px;
  font-style: normal;
  font-weight: 600;
}
.ch-grp__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

Bullet charts against a target

  • hard-stop bands
  • target marker
  • list as source
Q3 sales targets Northwind Cycles, quarter to date
  • Revenue$412k of $450k
    92% of target
  • New customers128 of 150
    85% of target
  • Net Promoter Score46, target 40
    Target beaten

Bands from dark to light: poor, fair, good. Orange line: target.

A bullet chart packs a value, a target and qualitative bands into one thin row. The bands are a hard-stop linear-gradient() driven by two custom properties, the bar and the orange target line are positioned with the same percentages, and the list text states value, target and progress in words.

View code
.ch-bul {
  display: grid;
  gap: 4px;
  width: min(100%, 520px);
  padding: 24px;
  border: 1px solid #263044;
  border-radius: 16px;
  background: #121826;
  color: #e5e7eb;
}
.ch-bul__title {
  font-size: 17px;
  font-weight: 700;
  color: #f8fafc;
}
.ch-bul__sub,
.ch-bul__key {
  margin: 0;
  font-size: 13px;
  color: #94a3b8;
}
.ch-bul__list {
  display: grid;
  gap: 18px;
  margin: 14px 0 12px;
  padding: 0;
  list-style: none;
}
.ch-bul__item {
  display: grid;
  gap: 7px;
}
.ch-bul__label {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  gap: 2px 12px;
  font-size: 14px;
}
.ch-bul__name {
  font-weight: 600;
}
.ch-bul__value {
  font-variant-numeric: tabular-nums;
  color: #cbd5e1;
}
.ch-bul__track {
  position: relative;
  height: 22px;
  border-radius: 3px;
  background: linear-gradient(90deg, #2a3344 0 calc(var(--ch-b1) * 1%), #364156 0 calc(var(--ch-b2) * 1%), #46526b 0);
}
.ch-bul__bar {
  position: absolute;
  top: 7px;
  left: 0;
  width: calc(var(--ch-v) * 1%);
  height: 8px;
  border-radius: 0 2px 2px 0;
  background: #e2e8f0;
}
.ch-bul__mark {
  position: absolute;
  top: 2px;
  bottom: 2px;
  left: calc(var(--ch-target) * 1%);
  width: 3px;
  border-radius: 2px;
  background: #fb923c;
  translate: -50% 0;
}
.ch-bul__status {
  justify-self: start;
  padding: 2px 8px;
  border-radius: 999px;
  background: #1f2937;
  font-size: 12px;
  font-weight: 600;
  color: #fdba74;
}
.ch-bul__status--ok {
  color: #86efac;
}

Pie, donut, gauge and ring charts

One conic-gradient() with hard color stops draws each of these. A mask cuts the hole, @property lets a ring sweep instead of jump, and the values sit next to the shape as real text.

Pie chart with labels placed by sin() and cos()

  • hard-stop conic-gradient
  • sin() / cos()
  • legend as source
Where the budget goes Kestrel Studio, 2026 operating costs of $640k
  • Payroll46%
  • Rent22%
  • Software18%
  • Marketing14%

Four custom properties build the cumulative stops of one conic-gradient(), with the 0 shorthand so each slice starts where the last one ended. Labels sit on the middle angle of their slice through sin() and cos(), gated by @supports, and the colors alternate dark and light so every neighbour pair clears 3:1.

View code
.ch-pie {
  width: min(100%, 540px);
  margin: 0;
  padding: 24px;
  border-radius: 22px;
  background: #fff;
  color: #111827;
  box-shadow: 0 20px 44px rgba(17, 24, 39, .08);
}
.ch-pie__head {
  display: grid;
  gap: 2px;
  margin-bottom: 18px;
}
.ch-pie__title {
  font: 600 20px/1.25 "Fraunces", Georgia, serif;
  letter-spacing: 0;
}
.ch-pie__sub {
  font-size: 13px;
  color: #4b5563;
}
.ch-pie__body {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 24px 32px;
}
.ch-pie__disc {
  position: relative;
  flex: none;
  width: min(100%, 220px);
  aspect-ratio: 1;
  border-radius: 50%;
  background: conic-gradient(
    #1e3a8a 0 calc(var(--ch-a) * 1%),
    #fbbf24 0 calc((var(--ch-a) + var(--ch-b)) * 1%),
    #9a3412 0 calc((var(--ch-a) + var(--ch-b) + var(--ch-c)) * 1%),
    #7dd3fc 0
  );
}
.ch-pie__tag {
  display: none;
}
@supports (left: calc(1% * sin(1deg))) {
  .ch-pie__tag {
    --ch-mid: calc((var(--ch-start) + var(--ch-v) / 2) * 3.6deg);
    position: absolute;
    left: calc(50% + sin(var(--ch-mid)) * 32%);
    top: calc(50% - cos(var(--ch-mid)) * 32%);
    display: block;
    translate: -50% -50%;
    font-size: 13px;
    font-weight: 700;
    color: #fff;
  }
  .ch-pie__tag--dark {
    color: #111827;
  }
}
.ch-pie__legend {
  display: grid;
  gap: 10px;
  min-width: 170px;
  margin: 0;
  padding: 0;
  list-style: none;
  font-size: 14px;
}
.ch-pie__legend li {
  display: grid;
  grid-template-columns: 14px 1fr auto;
  align-items: center;
  gap: 10px;
}
.ch-pie__legend b {
  font-variant-numeric: tabular-nums;
}
.ch-pie__key {
  width: 14px;
  height: 14px;
  border-radius: 50%;
}
.ch-pie__key--1 {
  background: #1e3a8a;
}
.ch-pie__key--2 {
  background: #fbbf24;
  box-shadow: inset 0 0 0 1px #92400e;
}
.ch-pie__key--3 {
  background: #9a3412;
}
.ch-pie__key--4 {
  background: #7dd3fc;
  box-shadow: inset 0 0 0 1px #0369a1;
}

Donut with the total in the middle

  • mask: radial-gradient()
  • sibling center label
  • gap stops
Support tickets by channel Moss and Pine, 1,284 tickets in October
  • Email66852%
  • Live chat39831%
  • Phone21817%

The ring is a pie with a radial-gradient() mask that keeps only the outer 30px. Because a mask clips everything inside the element, the total lives in a sibling layered on top. Thin white stops between segments separate the slices, and the three greens sit at least 3:1 apart from each other.

View code
.ch-donut {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 24px 32px;
  width: min(100%, 560px);
  margin: 0;
  padding: 26px;
  border-radius: 18px;
  background: #f4f7f2;
  color: #0a3326;
  box-shadow: inset 0 0 0 1px #dde6d8;
}
.ch-donut__ring {
  position: relative;
  flex: none;
  width: min(100%, 200px);
  aspect-ratio: 1;
}
.ch-donut__disc {
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background: conic-gradient(
    #0a3326 0 calc(var(--ch-a) * 1% - .6%),
    #f4f7f2 0 calc(var(--ch-a) * 1%),
    #328552 0 calc((var(--ch-a) + var(--ch-b)) * 1% - .6%),
    #f4f7f2 0 calc((var(--ch-a) + var(--ch-b)) * 1%),
    #d9f99d 0 99.4%,
    #f4f7f2 0
  );
  -webkit-mask: radial-gradient(farthest-side, #0000 calc(100% - 30px), #000 calc(100% - 29px));
  mask: radial-gradient(farthest-side, #0000 calc(100% - 30px), #000 calc(100% - 29px));
}
.ch-donut__center {
  position: absolute;
  inset: 0;
  display: grid;
  place-content: center;
  text-align: center;
}
.ch-donut__center b {
  font: 600 32px/1 "Fraunces", Georgia, serif;
  letter-spacing: -.01em;
}
.ch-donut__center span {
  font-size: 13px;
  color: #3d5a4c;
}
.ch-donut__side {
  display: grid;
  gap: 16px;
  min-width: min(100%, 220px);
}
.ch-donut__head {
  display: grid;
  gap: 2px;
}
.ch-donut__title {
  font-size: 17px;
  font-weight: 700;
}
.ch-donut__sub {
  font-size: 13px;
  color: #3d5a4c;
}
.ch-donut__legend {
  display: grid;
  gap: 10px;
  margin: 0;
  padding: 0;
  list-style: none;
  font-size: 14px;
}
.ch-donut__legend li {
  display: grid;
  grid-template-columns: 14px 1fr auto 3rem;
  align-items: center;
  gap: 10px;
}
.ch-donut__legend b {
  font-variant-numeric: tabular-nums;
}
.ch-donut__legend small {
  font-size: 13px;
  text-align: right;
  color: #3d5a4c;
}
.ch-donut__key {
  width: 14px;
  height: 14px;
  border-radius: 4px;
}
.ch-donut__key--1 {
  background: #0a3326;
}
.ch-donut__key--2 {
  background: #328552;
}
.ch-donut__key--3 {
  background: #d9f99d;
  box-shadow: inset 0 0 0 1px #328552;
}

Semicircle gauge backed by a meter element

  • conic-gradient at 50% 100%
  • meter
  • aria-valuetext
Disk usage db-primary-02, volume /data 76%

Drag the slider to feed the gauge a new value.

The arc is a conic-gradient() centered on the bottom edge of a 2:1 box, starting at -90deg so 180 degrees covers the scale. A native meter with low and high thresholds carries the value for assistive technology, and the zone is spelled out as Healthy, Warning or Critical next to the color.

View code
.ch-gauge {
  --ch-fill: #2dd4bf;
  display: grid;
  justify-items: center;
  gap: 4px;
  width: min(100%, 360px);
  padding: 24px 24px 20px;
  border-radius: 24px;
  background: #0f1522;
  color: #e2e8f0;
  box-shadow: inset 0 0 0 1px #1f2a3d;
  text-align: center;
}
.ch-gauge[data-zone="warn"] {
  --ch-fill: #fbbf24;
}
.ch-gauge[data-zone="crit"] {
  --ch-fill: #f87171;
}
.ch-gauge__title {
  font-size: 17px;
  font-weight: 700;
  color: #f8fafc;
}
.ch-gauge__sub {
  font-size: 13px;
  color: #94a3b8;
}
.ch-gauge__dial {
  position: relative;
  width: min(100%, 260px);
  aspect-ratio: 2 / 1;
  margin: 16px 0 26px;
}
.ch-gauge__arc {
  position: absolute;
  inset: 0;
  border-radius: 999px 999px 0 0;
  background: conic-gradient(from -90deg at 50% 100%, var(--ch-fill) 0 calc(var(--ch-v) * 1.8deg), #263043 0 126deg, #4a3b17 0 162deg, #4d2328 0 180deg, #0000 0);
  -webkit-mask: radial-gradient(circle farthest-side at 50% 100%, #0000 calc(100% - 26px), #000 calc(100% - 25px));
  mask: radial-gradient(circle farthest-side at 50% 100%, #0000 calc(100% - 26px), #000 calc(100% - 25px));
}
.ch-gauge__read {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  display: grid;
}
.ch-gauge__num {
  font-size: 36px;
  font-weight: 700;
  line-height: 1.1;
  font-variant-numeric: tabular-nums;
  color: #f8fafc;
}
.ch-gauge__state {
  font-size: 14px;
  font-weight: 600;
  color: var(--ch-fill);
}
.ch-gauge__end {
  position: absolute;
  top: calc(100% + 6px);
  font-size: 12px;
  color: #94a3b8;
}
.ch-gauge__end--min {
  left: 6px;
}
.ch-gauge__end--max {
  right: 0;
}
.ch-gauge__sr,
.ch-gauge__meter {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
.ch-gauge__control {
  display: grid;
  gap: 6px;
  width: 100%;
  font-size: 13px;
  color: #cbd5e1;
}
.ch-gauge__range {
  width: 100%;
  height: 28px;
  margin: 0;
  accent-color: #2dd4bf;
  cursor: pointer;
}
.ch-gauge__range:focus-visible {
  outline: 3px solid #93c5fd;
  outline-offset: 3px;
}

Progress rings that sweep in with @property

  • @property
  • conic-gradient
  • mask on ::before
Sprint 42 health Team Atlas, 3 days left
  • 75% Stories done 18 of 24
  • 55% Bugs closed 11 of 20
  • 92% Code reviewed 46 of 50 PRs

Registering --ch-ring as a number lets the browser interpolate it, so the conic stop sweeps around instead of jumping. The ring is drawn on ::before and masked there, which leaves the percentage text in the element itself untouched. The sweep only runs for visitors who have not asked for reduced motion.

View code
@property --ch-ring {
  syntax: "<number>";
  inherits: false;
  initial-value: 0;
}
.ch-rings {
  display: grid;
  gap: 2px;
  width: min(100%, 520px);
  padding: 24px;
  border-radius: 20px;
  background: #fff;
  color: #1e1b2e;
  box-shadow: 0 16px 40px rgba(30, 27, 46, .08);
}
.ch-rings__title {
  font-size: 17px;
  font-weight: 700;
}
.ch-rings__sub {
  font-size: 13px;
  color: #57536a;
}
.ch-rings__list {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 12px;
  margin: 18px 0 0;
  padding: 0;
  list-style: none;
}
.ch-rings__item {
  display: grid;
  justify-items: center;
  gap: 2px;
  text-align: center;
}
.ch-rings__ring {
  --ch-c: #6d28d9;
  position: relative;
  display: grid;
  place-items: center;
  width: min(100%, 104px);
  aspect-ratio: 1;
  margin-bottom: 8px;
}
.ch-rings__ring--2 {
  --ch-c: #be185d;
}
.ch-rings__ring--3 {
  --ch-c: #0369a1;
}
.ch-rings__ring::before {
  content: "";
  --ch-ring: var(--ch-p);
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background: conic-gradient(var(--ch-c) calc(var(--ch-ring) * 1%), #ece9f5 0);
  -webkit-mask: radial-gradient(farthest-side, #0000 calc(100% - 11px), #000 calc(100% - 10px));
  mask: radial-gradient(farthest-side, #0000 calc(100% - 11px), #000 calc(100% - 10px));
}
.ch-rings__ring b {
  position: relative;
  font-size: 19px;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
}
.ch-rings__label {
  font-size: 14px;
  font-weight: 600;
  line-height: 1.25;
}
.ch-rings__detail {
  font-size: 12px;
  color: #57536a;
}
@media (prefers-reduced-motion: no-preference) {
  .ch-rings__ring::before {
    animation: ch-rings-fill 1.2s cubic-bezier(.2, .7, .2, 1) both;
  }
  .ch-rings__item:nth-child(2) .ch-rings__ring::before {
    animation-delay: .15s;
  }
  .ch-rings__item:nth-child(3) .ch-rings__ring::before {
    animation-delay: .3s;
  }
}
@keyframes ch-rings-fill {
  from {
    --ch-ring: 0;
  }
}

Line, area, scatter and sparkline charts

The shapes CSS finds hardest. Lines come from an SVG polyline or a clip-path polygon built from custom properties, and scatter points are absolutely positioned from an --x and --y pair.

Line chart with an SVG polyline and HTML dots

  • vector-effect
  • preserveAspectRatio none
  • details table
Monthly active users Tallyhub, January to October 2026
Show the numbers
Monthly active users, 2026, in thousands
MonthUsers
January12.4
February13.1
March13.0
April14.6
May15.2
June16.8
July16.1
August17.9
September19.4
October21.0

Open Show the numbers to reveal the table.

The SVG stretches to any width with preserveAspectRatio="none", and vector-effect: non-scaling-stroke keeps the line 2.5px thick anyway. The dots are HTML spans placed from --ch-x and --ch-y, so they stay round instead of turning into ovals. The plot has a summary as its accessible name and the exact numbers sit in a details table.

View code
.ch-line {
  container-type: inline-size;
  display: grid;
  gap: 16px;
  width: min(100%, 580px);
  margin: 0;
  padding: 22px 22px 18px;
  border-radius: 16px;
  background: #fff;
  color: #1f1720;
  box-shadow: 0 0 0 1px #f0e4e8, 0 18px 40px rgba(76, 5, 25, .07);
}
.ch-line__head {
  display: grid;
  gap: 2px;
}
.ch-line__title {
  font-size: 17px;
  font-weight: 700;
}
.ch-line__sub {
  font-size: 13px;
  color: #5f5560;
}
.ch-line__plot {
  position: relative;
  height: 210px;
  padding: 10px 16px 28px 40px;
}
.ch-line__grid {
  position: absolute;
  inset: 10px 0 28px 0;
}
.ch-line__grid span {
  position: absolute;
  left: 0;
  right: 0;
  bottom: calc(var(--ch-t) * 1%);
  height: 0;
  font-size: 11px;
  line-height: 0;
  color: #5f5560;
}
.ch-line__grid span::after {
  content: "";
  position: absolute;
  left: 40px;
  right: 0;
  top: 0;
  border-top: 1px solid #f1e7ea;
}
.ch-line__area {
  position: relative;
  height: 100%;
}
.ch-line__svg {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: visible;
}
.ch-line__svg polyline {
  fill: none;
  stroke: #e11d48;
  stroke-width: 2.5;
  stroke-linejoin: round;
  stroke-linecap: round;
  vector-effect: non-scaling-stroke;
}
.ch-line__dot {
  position: absolute;
  left: calc(var(--ch-x) * 1%);
  bottom: calc(var(--ch-y) * 1%);
  width: 10px;
  height: 10px;
  border: 2.5px solid #e11d48;
  border-radius: 50%;
  background: #fff;
  translate: -50% 50%;
}
.ch-line__dot--last {
  background: #e11d48;
}
.ch-line__dot b {
  position: absolute;
  right: -4px;
  bottom: calc(100% + 6px);
  padding: 2px 6px;
  border-radius: 6px;
  background: #1f1720;
  font-size: 11px;
  font-weight: 700;
  color: #fff;
  white-space: nowrap;
}
.ch-line__x {
  position: absolute;
  top: calc(100% + 8px);
  left: calc(var(--ch-x) * 1%);
  translate: -50% 0;
  font-size: 11px;
  color: #5f5560;
}
@container (max-width: 420px) {
  .ch-line__x--minor {
    display: none;
  }
}
.ch-line__data {
  font-size: 14px;
}
.ch-line__data summary {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  padding: 0 4px;
  font-weight: 600;
  color: #9f1239;
  cursor: pointer;
}
.ch-line__data summary:focus-visible {
  outline: 3px solid #1847f0;
  outline-offset: 2px;
  border-radius: 4px;
}
.ch-line__data table {
  width: 100%;
  border-collapse: collapse;
}
.ch-line__data caption {
  padding: 4px 0 8px;
  text-align: left;
  color: #5f5560;
}
.ch-line__data th,
.ch-line__data td {
  padding: 5px 4px;
  border-bottom: 1px solid #f1e7ea;
  text-align: left;
  font-weight: 400;
}
.ch-line__data td {
  text-align: right;
  font-variant-numeric: tabular-nums;
}

Area chart cut with a clip-path polygon

  • clip-path: polygon()
  • custom property points
  • threshold line
API response time p95 per hour, today 08:00 to 19:00
Peak 340 ms at 18:00
API p95 response time per hour today, in milliseconds. Objective 300 ms.
HourResponse time
08:00180
09:00165
10:00170
11:00210
12:00260
13:00320
14:00290
15:00240
16:00230
17:00275
18:00340
19:00300

Twelve custom properties feed one polygon() whose vertices sit at calc(100% - var(--ch-pN) * 1%). The light edge is the same polygon on a second layer, peeking out 2px above the filled copy. A dashed line marks the 300 ms objective, and the hourly figures live in a visually hidden table.

View code
.ch-area {
  position: relative;
  display: grid;
  gap: 18px;
  padding: clamp(22px, 5vw, 40px);
  background: #0d1321;
  color: #e2e8f0;
}
.ch-area__top {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  justify-content: space-between;
  gap: 10px 20px;
}
.ch-area__title {
  display: block;
  font-size: 18px;
  font-weight: 700;
  color: #f8fafc;
}
.ch-area__sub {
  display: block;
  font-size: 13px;
  color: #94a3b8;
}
.ch-area__peak {
  padding: 5px 10px;
  border: 1px solid #3b4a63;
  border-radius: 999px;
  font-size: 12px;
  font-weight: 600;
  color: #fecdd3;
}
.ch-area__plot {
  position: relative;
  height: 240px;
  padding: 8px 0 26px 44px;
}
.ch-area__grid {
  position: absolute;
  inset: 8px 0 26px 0;
}
.ch-area__grid span {
  position: absolute;
  left: 0;
  right: 0;
  bottom: calc(var(--ch-t) * 1%);
  height: 0;
  font-size: 11px;
  line-height: 0;
  color: #94a3b8;
}
.ch-area__grid span::after {
  content: "";
  position: absolute;
  left: 44px;
  right: 0;
  top: 0;
  border-top: 1px solid #1e293b;
}
.ch-area__box {
  position: relative;
  height: 100%;
  overflow: hidden;
}
.ch-area__shape {
  position: absolute;
  inset: 0;
  clip-path: polygon(
    0% 100%,
    0% calc(100% - var(--ch-p1) * 1%),
    9.09% calc(100% - var(--ch-p2) * 1%),
    18.18% calc(100% - var(--ch-p3) * 1%),
    27.27% calc(100% - var(--ch-p4) * 1%),
    36.36% calc(100% - var(--ch-p5) * 1%),
    45.45% calc(100% - var(--ch-p6) * 1%),
    54.55% calc(100% - var(--ch-p7) * 1%),
    63.64% calc(100% - var(--ch-p8) * 1%),
    72.73% calc(100% - var(--ch-p9) * 1%),
    81.82% calc(100% - var(--ch-p10) * 1%),
    90.91% calc(100% - var(--ch-p11) * 1%),
    100% calc(100% - var(--ch-p12) * 1%),
    100% 100%
  );
}
.ch-area__shape--edge {
  background: #e0f2fe;
}
.ch-area__shape--fill {
  background: #1d6fa5;
  translate: 0 2px;
}
.ch-area__slo {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 75%;
  border-top: 2px dashed #fb7185;
}
.ch-area__slo b {
  position: absolute;
  right: 0;
  bottom: 4px;
  padding: 2px 6px;
  border-radius: 4px;
  background: #0d1321;
  font-size: 11px;
  font-weight: 700;
  color: #fecdd3;
}
.ch-area__x {
  position: absolute;
  left: 44px;
  right: 0;
  bottom: 0;
  height: 18px;
}
.ch-area__x span {
  position: absolute;
  font-size: 11px;
  color: #94a3b8;
  translate: -50% 0;
}
.ch-area__x span:nth-child(1) { left: 0%; translate: 0 0; }
.ch-area__x span:nth-child(2) { left: 18.18%; }
.ch-area__x span:nth-child(3) { left: 36.36%; }
.ch-area__x span:nth-child(4) { left: 54.55%; }
.ch-area__x span:nth-child(5) { left: 72.73%; }
.ch-area__x span:nth-child(6) { left: 90.91%; }
.ch-area__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

Scatter plot with focusable points

  • absolute --x / --y
  • shape + color
  • :focus-visible tooltips
Page weight against LCP Ten client sites, median mobile load
  • Alder: 420 KB, LCP 1.1 s, static
  • Birch: 610 KB, LCP 1.4 s, static
  • Cedar: 380 KB, LCP 0.9 s, static
  • Dune: 820 KB, LCP 1.7 s, static
  • Elm: 540 KB, LCP 1.3 s, static
  • Fjord: 1,450 KB, LCP 2.9 s, single-page app
  • Grove: 1,120 KB, LCP 2.4 s, single-page app
  • Heron: 1,780 KB, LCP 3.5 s, single-page app
  • Iris: 960 KB, LCP 2.1 s, single-page app
  • Juniper: 1,320 KB, LCP 3.1 s, single-page app

Hover a point or press Tab to move between them.

Each point is a list item placed with left: calc(var(--ch-x) * 1%) and bottom: calc(var(--ch-y) * 1%). The two groups differ in shape and fill (solid teal circles against hollow amber diamonds), and every point holds its full label as text, which appears as a tooltip on hover or keyboard focus.

View code
.ch-scat {
  display: grid;
  gap: 14px;
  width: min(100%, 560px);
  margin: 0;
  padding: 22px 22px 18px;
  border-radius: 18px;
  background: #fdfcf8;
  color: #1c1917;
  box-shadow: 0 0 0 1px #ebe6da, 0 16px 36px rgba(28, 25, 23, .07);
}
.ch-scat__head {
  display: grid;
  gap: 2px;
}
.ch-scat__title {
  font-size: 17px;
  font-weight: 700;
}
.ch-scat__sub {
  font-size: 13px;
  color: #57534e;
}
.ch-scat__legend {
  display: flex;
  flex-wrap: wrap;
  gap: 6px 18px;
  margin: 0;
  padding: 0;
  list-style: none;
  font-size: 13px;
}
.ch-scat__legend li {
  display: flex;
  align-items: center;
  gap: 8px;
}
.ch-scat__mark {
  width: 12px;
  height: 12px;
  border-radius: 50%;
  background: #0f766e;
}
.ch-scat__mark--spa {
  width: 10px;
  height: 10px;
  border: 2.5px solid #b45309;
  border-radius: 2px;
  background: #fff;
  rotate: 45deg;
}
.ch-scat__plot {
  position: relative;
  height: 250px;
  margin: 0 8px 22px 34px;
  border-left: 1.5px solid #a8a29e;
  border-bottom: 1.5px solid #a8a29e;
}
.ch-scat__axes {
  position: absolute;
  inset: 0;
}
.ch-scat__y {
  position: absolute;
  right: calc(100% + 8px);
  bottom: calc(var(--ch-t) * 1%);
  translate: 0 50%;
  font-size: 11px;
  white-space: nowrap;
  color: #57534e;
}
.ch-scat__xl {
  position: absolute;
  top: calc(100% + 6px);
  font-size: 11px;
  white-space: nowrap;
  color: #57534e;
}
.ch-scat__xl--mid {
  left: 50%;
  translate: -50% 0;
}
.ch-scat__xl--end {
  right: 0;
}
.ch-scat__points {
  position: absolute;
  inset: 0;
  margin: 0;
  padding: 0;
  list-style: none;
}
.ch-scat__pt {
  position: absolute;
  left: calc(var(--ch-x) * 1%);
  bottom: calc(var(--ch-y) * 1%);
  width: 24px;
  height: 24px;
  border-radius: 50%;
  translate: -50% 50%;
  cursor: default;
}
.ch-scat__pt::before {
  content: "";
  position: absolute;
  inset: 6px;
  border-radius: 50%;
  background: #0f766e;
}
.ch-scat__pt--spa::before {
  inset: 7px;
  border: 2.5px solid #b45309;
  border-radius: 2px;
  background: #fff;
  rotate: 45deg;
}
.ch-scat__pt:focus-visible {
  outline: 2.5px solid #1847f0;
  outline-offset: 1px;
}
.ch-scat__tip {
  position: absolute;
  bottom: calc(100% + 6px);
  left: 50%;
  z-index: 2;
  padding: 6px 9px;
  border-radius: 8px;
  background: #1c1917;
  font-size: 12px;
  font-weight: 500;
  line-height: 1.3;
  color: #fff;
  white-space: nowrap;
  translate: -50% 0;
  opacity: 0;
  pointer-events: none;
  transition: opacity .15s;
}
.ch-scat__pt--end .ch-scat__tip {
  left: auto;
  right: -4px;
  translate: 0 0;
}
.ch-scat__pt--below .ch-scat__tip {
  top: calc(100% + 6px);
  bottom: auto;
}
.ch-scat__pt:hover,
.ch-scat__pt:focus {
  z-index: 3;
}
.ch-scat__pt:hover .ch-scat__tip,
.ch-scat__pt:focus .ch-scat__tip {
  opacity: 1;
}

Sparklines inside table cells

  • clip-path line
  • tabular-nums
  • hidden series text
Revenue by planLattice CRM, last 8 weeks, weekly revenue
PlanTrendThis week8 weeks
StarterWeekly, in thousands of dollars: 3.6, 3.7, 3.9, 3.8, 4.0, 3.9, 4.1, 4.2$4.2k+17%
TeamWeekly, in thousands of dollars: 11.4, 11.6, 11.3, 11.9, 12.2, 12.0, 12.5, 12.8$12.8k+12%
BusinessWeekly, in thousands of dollars: 9.6, 9.8, 9.5, 9.4, 9.7, 9.2, 9.0, 9.1$9.1k−5%
EnterpriseWeekly, in thousands of dollars: 20.1, 20.4, 20.9, 20.6, 21.0, 21.2, 21.0, 21.4$21.4k+6%

A sparkline is a line polygon: the path runs forward along the eight points and back along the same points 2px lower, which leaves a 2px stroke. The values come from --ch-v1 to --ch-v8, already scaled to the cell, and a visually hidden sentence in the same cell lists the real weekly numbers.

View code
.ch-spark {
  width: min(100%, 560px);
  padding: 20px 20px 10px;
  border-radius: 14px;
  background: #fff;
  color: #18181b;
  box-shadow: 0 0 0 1px #e4e4e7, 0 12px 28px rgba(24, 24, 27, .06);
}
.ch-spark__table {
  width: 100%;
  border-collapse: collapse;
  font-size: 14px;
  font-variant-numeric: tabular-nums;
}
.ch-spark__cap {
  padding-bottom: 12px;
  text-align: left;
}
.ch-spark__title {
  display: block;
  font-size: 17px;
  font-weight: 700;
}
.ch-spark__sub {
  display: block;
  font-size: 13px;
  color: #52525b;
}
.ch-spark__table th,
.ch-spark__table td {
  padding: 10px 6px;
  border-top: 1px solid #ececef;
  text-align: right;
  vertical-align: middle;
  white-space: nowrap;
}
.ch-spark__table th:first-child {
  padding-left: 0;
  text-align: left;
}
.ch-spark__table td:last-child,
.ch-spark__table th:last-child {
  padding-right: 0;
}
.ch-spark__table thead th {
  border-top: 0;
  font-size: 12px;
  font-weight: 600;
  color: #52525b;
}
.ch-spark__table tbody th {
  font-weight: 600;
}
.ch-spark__table td:nth-child(2) {
  width: 40%;
}
.ch-spark__line {
  position: relative;
  display: block;
  width: 100%;
  min-width: 56px;
  height: 28px;
  background: #4338ca;
  clip-path: polygon(
    0% calc(100% - var(--ch-v1) * 1%),
    14.29% calc(100% - var(--ch-v2) * 1%),
    28.57% calc(100% - var(--ch-v3) * 1%),
    42.86% calc(100% - var(--ch-v4) * 1%),
    57.14% calc(100% - var(--ch-v5) * 1%),
    71.43% calc(100% - var(--ch-v6) * 1%),
    85.71% calc(100% - var(--ch-v7) * 1%),
    100% calc(100% - var(--ch-v8) * 1%),
    100% calc(100% - var(--ch-v8) * 1% + 2px),
    85.71% calc(100% - var(--ch-v7) * 1% + 2px),
    71.43% calc(100% - var(--ch-v6) * 1% + 2px),
    57.14% calc(100% - var(--ch-v5) * 1% + 2px),
    42.86% calc(100% - var(--ch-v4) * 1% + 2px),
    28.57% calc(100% - var(--ch-v3) * 1% + 2px),
    14.29% calc(100% - var(--ch-v2) * 1% + 2px),
    0% calc(100% - var(--ch-v1) * 1% + 2px)
  );
}
.ch-spark__row--down .ch-spark__line {
  background: #b91c1c;
}
.ch-spark__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
.ch-spark__chg {
  display: inline-block;
  padding: 2px 7px;
  border-radius: 6px;
  background: #ecfdf5;
  font-size: 13px;
  font-weight: 700;
  color: #047857;
}
.ch-spark__row--down .ch-spark__chg {
  background: #fef2f2;
  color: #b91c1c;
}

Heatmaps, funnels and motion

A calendar heatmap that uses size as well as color, a funnel cut with clip-path trapezoids, and a column chart that grows in as it scrolls into view, with the motion gated behind @supports and prefers-reduced-motion.

Calendar heatmap with size and color

  • grid-auto-flow: column
  • scale by level
  • ordered list
Deploys per day Orbit API, 3 Aug to 25 Oct 2026, 287 deploys
  1. Mon 3 Aug: 4 deploys
  2. Tue 4 Aug: 3 deploys
  3. Wed 5 Aug: 5 deploys
  4. Thu 6 Aug: 1 deploy
  5. Fri 7 Aug: 2 deploys
  6. Sat 8 Aug: 1 deploy
  7. Sun 9 Aug: 0 deploys
  8. Mon 10 Aug: 4 deploys
  9. Tue 11 Aug: 9 deploys
  10. Wed 12 Aug: 1 deploy
  11. Thu 13 Aug: 7 deploys
  12. Fri 14 Aug: 3 deploys
  13. Sat 15 Aug: 0 deploys
  14. Sun 16 Aug: 0 deploys
  15. Mon 17 Aug: 5 deploys
  16. Tue 18 Aug: 5 deploys
  17. Wed 19 Aug: 2 deploys
  18. Thu 20 Aug: 3 deploys
  19. Fri 21 Aug: 2 deploys
  20. Sat 22 Aug: 1 deploy
  21. Sun 23 Aug: 1 deploy
  22. Mon 24 Aug: 1 deploy
  23. Tue 25 Aug: 9 deploys
  24. Wed 26 Aug: 2 deploys
  25. Thu 27 Aug: 3 deploys
  26. Fri 28 Aug: 9 deploys
  27. Sat 29 Aug: 0 deploys
  28. Sun 30 Aug: 1 deploy
  29. Mon 31 Aug: 9 deploys
  30. Tue 1 Sep: 5 deploys
  31. Wed 2 Sep: 1 deploy
  32. Thu 3 Sep: 3 deploys
  33. Fri 4 Sep: 1 deploy
  34. Sat 5 Sep: 1 deploy
  35. Sun 6 Sep: 0 deploys
  36. Mon 7 Sep: 4 deploys
  37. Tue 8 Sep: 5 deploys
  38. Wed 9 Sep: 3 deploys
  39. Thu 10 Sep: 7 deploys
  40. Fri 11 Sep: 2 deploys
  41. Sat 12 Sep: 1 deploy
  42. Sun 13 Sep: 0 deploys
  43. Mon 14 Sep: 7 deploys
  44. Tue 15 Sep: 3 deploys
  45. Wed 16 Sep: 2 deploys
  46. Thu 17 Sep: 9 deploys
  47. Fri 18 Sep: 9 deploys
  48. Sat 19 Sep: 2 deploys
  49. Sun 20 Sep: 0 deploys
  50. Mon 21 Sep: 4 deploys
  51. Tue 22 Sep: 2 deploys
  52. Wed 23 Sep: 7 deploys
  53. Thu 24 Sep: 2 deploys
  54. Fri 25 Sep: 9 deploys
  55. Sat 26 Sep: 0 deploys
  56. Sun 27 Sep: 1 deploy
  57. Mon 28 Sep: 3 deploys
  58. Tue 29 Sep: 6 deploys
  59. Wed 30 Sep: 7 deploys
  60. Thu 1 Oct: 5 deploys
  61. Fri 2 Oct: 4 deploys
  62. Sat 3 Oct: 1 deploy
  63. Sun 4 Oct: 1 deploy
  64. Mon 5 Oct: 6 deploys
  65. Tue 6 Oct: 4 deploys
  66. Wed 7 Oct: 4 deploys
  67. Thu 8 Oct: 3 deploys
  68. Fri 9 Oct: 3 deploys
  69. Sat 10 Oct: 2 deploys
  70. Sun 11 Oct: 0 deploys
  71. Mon 12 Oct: 2 deploys
  72. Tue 13 Oct: 9 deploys
  73. Wed 14 Oct: 4 deploys
  74. Thu 15 Oct: 7 deploys
  75. Fri 16 Oct: 6 deploys
  76. Sat 17 Oct: 0 deploys
  77. Sun 18 Oct: 2 deploys
  78. Mon 19 Oct: 6 deploys
  79. Tue 20 Oct: 4 deploys
  80. Wed 21 Oct: 9 deploys
  81. Thu 22 Oct: 2 deploys
  82. Fri 23 Oct: 2 deploys
  83. Sat 24 Oct: 1 deploy
  84. Sun 25 Oct: 1 deploy

Levels: none, 1 to 2, 3 to 4, 5 to 6, 7 or more deploys.

Seven grid rows and grid-auto-flow: column turn a flat list of 84 days into weeks. Each day carries a level from 0 to 4 that sets both its shade and its dot size, so busy days stand out even without color vision. Every cell holds its date and count as hidden text.

View code
.ch-heat {
  display: grid;
  gap: 16px;
  width: min(100%, 500px);
  padding: 22px;
  border-radius: 18px;
  background: #111827;
  color: #e5e7eb;
  box-shadow: inset 0 0 0 1px #1f2937;
}
.ch-heat__title {
  display: block;
  font-size: 17px;
  font-weight: 700;
  color: #f9fafb;
}
.ch-heat__sub,
.ch-heat__key {
  display: block;
  margin: 0;
  font-size: 13px;
  color: #9ca3af;
}
.ch-heat__wrap {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  gap: 8px;
}
.ch-heat__days {
  display: grid;
  grid-template-rows: repeat(7, 1fr);
  gap: 4px;
  margin-top: 22px;
  font-size: 11px;
  color: #9ca3af;
}
.ch-heat__days span {
  display: flex;
  align-items: center;
}
.ch-heat__months {
  display: grid;
  grid-template-columns: repeat(12, minmax(0, 1fr));
  height: 18px;
  margin-bottom: 4px;
  font-size: 11px;
  color: #9ca3af;
}
.ch-heat__months span {
  grid-column: var(--ch-col) / span 3;
}
.ch-heat__grid {
  display: grid;
  grid-template-columns: repeat(12, minmax(0, 1fr));
  grid-template-rows: repeat(7, auto);
  grid-auto-flow: column;
  gap: 4px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.ch-heat__grid li,
.ch-heat__legend i {
  position: relative;
  display: block;
  aspect-ratio: 1;
  border-radius: 5px;
}
.ch-heat__grid li::before,
.ch-heat__legend i::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: color-mix(in oklab, #e0e7ff calc(var(--ch-l) * 25%), #2e3375);
  scale: calc(.5 + var(--ch-l) * .125);
}
.ch-heat__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
.ch-heat__legend {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 4px;
  font-size: 11px;
  color: #9ca3af;
}
.ch-heat__legend i {
  width: 16px;
}
.ch-heat__legend span:first-child {
  margin-right: 4px;
}
.ch-heat__legend span:last-child {
  margin-left: 4px;
}

Funnel cut from clip-path trapezoids

  • clip-path trapezoid
  • step conversion
  • ordered list
Trial funnel, September Brightdesk, visitors who reached the pricing page
  1. 8,000 visited pricing100% of visitors
  2. 4,400 started a trial55% of visitors
  3. 2,800 activated64% of trials
  4. 1,200 paid43% of activated, 15% overall

Each step is a trapezoid whose top edge is its own share and whose bottom edge is the next step, so the stack reads as one continuous funnel. The ordered list holds every count and both conversion rates as text, which matters more here than anywhere: the drop between steps is the point of a funnel.

View code
.ch-fun {
  display: grid;
  gap: 2px;
  width: min(100%, 540px);
  padding: 24px;
  border-radius: 20px;
  background: #fff;
  color: #2a0f17;
  box-shadow: 0 18px 44px rgba(76, 5, 25, .08);
}
.ch-fun__title {
  font: 600 20px/1.25 "Fraunces", Georgia, serif;
  letter-spacing: 0;
}
.ch-fun__sub {
  font-size: 13px;
  color: #6b4a53;
}
.ch-fun__list {
  display: grid;
  gap: 3px;
  margin: 18px 0 0;
  padding: 0;
  list-style: none;
}
.ch-fun__step {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(8.5rem, 42%);
  align-items: center;
  gap: 14px;
}
.ch-fun__shape {
  height: 58px;
  background: #9f1239;
  clip-path: polygon(
    calc(50% - var(--ch-a) * .5%) 0,
    calc(50% + var(--ch-a) * .5%) 0,
    calc(50% + var(--ch-b) * .5%) 100%,
    calc(50% - var(--ch-b) * .5%) 100%
  );
}
.ch-fun__step:nth-child(2) .ch-fun__shape {
  background: #be123c;
}
.ch-fun__step:nth-child(3) .ch-fun__shape {
  background: #e11d48;
}
.ch-fun__step:nth-child(4) .ch-fun__shape {
  background: #f43f5e;
}
.ch-fun__text {
  display: grid;
  font-size: 14px;
  line-height: 1.3;
}
.ch-fun__text b {
  font-size: 18px;
  font-variant-numeric: tabular-nums;
}
.ch-fun__text small {
  font-size: 12px;
  color: #6b4a53;
}

Columns that grow in on scroll

  • view-timeline
  • @supports
  • prefers-reduced-motion
Rooftop solar output Harbour Street depot, kWh per month, 2025
Rooftop solar output per month in 2025, in kWh. Total 4,080 kWh.
MonthkWh
January120
February190
March310
April420
May510
June560
July580
August500
September380
October260
November150
December100

Scroll the page: the columns grow as the chart comes into view.

The plot declares a named view timeline and every column scales up from its baseline as the chart enters the viewport, each one starting a little later than the last. Both @supports (animation-timeline: view()) and prefers-reduced-motion: no-preference must pass; otherwise the columns simply render at full height. Value labels appear once the chart is wide enough to fit them.

View code
.ch-grow {
  position: relative;
  container-type: inline-size;
  display: grid;
  gap: 16px;
  width: min(100%, 600px);
  margin: 0;
  padding: 22px;
  border-radius: 18px;
  background: #f7fbe9;
  color: #14290f;
  box-shadow: inset 0 0 0 1px #e0ebc4;
}
.ch-grow__head {
  display: grid;
  gap: 2px;
}
.ch-grow__title {
  font-size: 17px;
  font-weight: 700;
}
.ch-grow__sub {
  font-size: 13px;
  color: #45573c;
}
.ch-grow__plot {
  position: relative;
  height: 220px;
  padding: 16px 0 0 32px;
  view-timeline: --ch-grow-tl block;
}
.ch-grow__grid {
  position: absolute;
  inset: 16px 0 24px 0;
}
.ch-grow__grid span {
  position: absolute;
  left: 0;
  right: 0;
  bottom: calc(var(--ch-t) / 600 * 100%);
  height: 0;
  font-size: 11px;
  line-height: 0;
  color: #45573c;
}
.ch-grow__grid span::after {
  content: "";
  position: absolute;
  left: 32px;
  right: 0;
  top: 0;
  border-top: 1px solid #dfe8c7;
}
.ch-grow__bars {
  position: relative;
  display: grid;
  grid-template-columns: repeat(12, minmax(0, 1fr));
  gap: clamp(3px, 1.4cqi, 10px);
  height: 100%;
}
.ch-grow__col {
  display: grid;
  grid-template-rows: minmax(0, 1fr) 24px;
  justify-items: center;
  min-width: 0;
}
.ch-grow__bar {
  position: relative;
  align-self: end;
  width: 100%;
  height: calc(var(--ch-v) / 600 * 100%);
  border-radius: 4px 4px 1px 1px;
  background: #15803d;
  transform-origin: 50% 100%;
}
.ch-grow__bar b {
  position: absolute;
  bottom: calc(100% + 3px);
  left: 50%;
  display: none;
  translate: -50% 0;
  font-size: 11px;
  font-weight: 700;
  line-height: 1;
}
@container (min-width: 480px) {
  .ch-grow__bar b {
    display: block;
  }
}
.ch-grow__col i {
  align-self: center;
  font-size: 12px;
  font-style: normal;
  font-weight: 600;
  color: #45573c;
}
.ch-grow__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .ch-grow__bar {
      animation: ch-grow-in linear both;
      animation-timeline: --ch-grow-tl;
      animation-range: entry calc(20% + var(--ch-i) * 3%) cover calc(42% + var(--ch-i) * 1.5%);
    }
  }
}
@keyframes ch-grow-in {
  from {
    scale: 1 0;
  }
}

What are CSS charts and which chart types can CSS draw?

Nothing here needs a charting library. Bars, slices and rings all come from properties like height, grid placement and gradients, written in CSS. No script has to download, parse or run before the shapes show up.

Which shapes come easily depends on what CSS already does to a box. Resizing it and filling it with a gradient get you most of the way.

  • Bar and column charts use a grid or flex container with one child per value, sized by percentage width or height.
  • A stacked bar puts several segments in one row, each sized as a share of the total.
  • One element with a conic-gradient() and hard color stops makes a pie.
  • Cut the center out with a mask or an overlaid radial-gradient() and the pie becomes a donut.
  • A progress ring is just one colored stop on a conic-gradient(), with the remainder transparent.

Line and area charts are the weak spot. They need diagonal edges and data points, so the shapes come from clip-path polygons that are fiddly to maintain, or from a framework such as Charts.css, whose site lists both chart types.

A script stays optional. One tutorial on Hashbangcode adds JavaScript mainly to build the gradient string from a data array (its accessible version also generates labelled slice elements), and the drawing itself is still CSS.

Single values don’t always need tricks either. The progress element handles task completion, and the meter element handles a value inside a known range, like a gauge reading. Both carry built-in meaning for assistive technology.

How does a CSS chart turn data into shapes?

Each value sits in a custom property, and calc() converts it into a size. A bar gets a percentage height, a slice gets a percentage color stop. The browser draws whatever the number resolves to, so the value in the markup is the only input the chart needs.

Where to store the value

The quickest route is an inline style, something like style=”–value: 72″ on each bar. It works anywhere custom properties work.

A data attribute (data-value=”72″) reads more cleanly in the markup, but CSS can only turn it into a size through typed attr(). Then there’s the table cell, where the number is visible text in a td and the size comes from a custom property set on that same cell.

Keeping the number in the HTML also keeps it readable when the stylesheet fails to load. That is why Charts.css and Mads Stoumann’s DEV Community walkthrough both build charts on a table.

Typed attr() is the catch. Can I use lists it as shipped in Chrome and Edge from version 133 and in Firefox from version 155, while stable Safari releases do not have it yet (only Safari Technology Preview does).

For production, I’d stick with an inline custom property. It’s the safer carrier.

Converting the value into a size

CSS does no scaling on its own. Charts.css expects a –size between 0 and 1, written as calc(40 / 100) for a value of 40 against a maximum of 100 (Charts.css docs and README).

The author divides by the maximum, and that division is the chart’s axis.

/* bar: a value from 0 to 100 becomes a height */
.bar { height: calc(var(--value) * 1%); }

/* ring: the same value becomes a gradient stop */
.ring { background: conic-gradient(var(--c) calc(var(--p) * 1%), #0000 0); }

The ring rule follows the pattern in freeCodeCamp’s single-element pie tutorial, where one custom property, –p, drives the fill.

How to build a bar chart with CSS grid

A bar chart is a grid with one child per value and a height tied to a custom property. Here’s the build, step by step.

  1. Add one element per value inside a wrapper and store each number in a –value custom property, scaled to a 0 to 100 range.
  2. Set the wrapper to display: grid with grid-auto-flow: column and grid-auto-columns: 1fr, so every bar gets an equal track.
  3. Add align-items: end so bars grow upward from a shared baseline.
  4. Give the wrapper a fixed height. Percentage heights resolve against it.
  5. Set each bar’s height to calc(var(–value) * 1%), then add gap, border-radius and labels.
<div class="chart">
<div class="bar" style="--value: 72"></div>
<div class="bar" style="--value: 45"></div>
</div>

.chart { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr;
  align-items: end; gap: 8px; height: 240px; }
.bar   { height: calc(var(--value) * 1%); border-radius: 4px 4px 0 0; }

Skip step 4 and the bars vanish. Without a height on the parent, a percentage height falls back to auto, and an empty element has no content to size it.

Flexbox works too, with align-items: flex-end. Grid is the cleaner pick for most bar charts because gridlines and bars can share the same tracks.

Horizontal and stacked bars

For horizontal bars, swap height for width and let the rows stack with grid-auto-flow: row.

Stacked bars go in a single flex row, with each segment’s flex-basis set to calc(var(–value) * 1%). The segment values have to add up to 100, otherwise the bar won’t fill its track.

How to draw pie, donut and progress ring charts with conic-gradient

All of these start from the same gradient. A pie is one element with one conic-gradient() whose color stops meet at hard edges, a donut adds a hole, and a ring keeps just a single colored stop. A 20, 45 and 35 percent split takes three stops, the example Keith Clark uses in his single-element pie tutorial.

Pie charts

.pie {
  width: 200px;
  aspect-ratio: 1;
  border-radius: 50%;
  background: conic-gradient(#e63946 0 20%, #457b9d 20% 65%, #2a9d8f 65% 100%);

}

Each stop is a color plus a start and an end position, and the gradient begins at 12 o’clock. The border-radius turns the square into a circle.

According to MDN Web Docs (2026), conic-gradient() has worked across browsers since November 2020.

MDN adds a warning. A gradient is a CSS image, and CSS images have no way to carry alternative text, so the data behind the pie must also exist as text in the document.

Segment count is the practical limit. Code Boxx calls the approach painful once a pie has many slices, and Keith Clark’s custom-property version needs enough stops written in advance to cover the largest pie it will ever show. So two independent tutorials land on the same ceiling.

Donut charts

The hole gets cut after the pie is drawn. You can put a radial-gradient() mask on the pie element, the method in both Mads Stoumann’s walkthrough and freeCodeCamp’s tutorial, or layer a radial-gradient() in the background color on top and size it to the ring thickness.

.donut { mask: radial-gradient(farthest-side, #0000 calc(100% - 36px), #000 0); }

A mask generator can write the mask-image value if the hole needs an unusual shape. One catch: a mask clips everything inside the element, labels included, so the center text belongs in a sibling element.

Progress rings

A ring is a pie with two stops. The first covers the value, the second is transparent.

The freeCodeCamp version feeds the value through calc(var(–p) * 1%) and adds pseudo-elements for rounded ends. Square ends need nothing extra.

How to animate CSS charts and keep them responsive

Bars animate with a plain transition or keyframe on height or width. Pies and rings need an @property registration first, so the browser can interpolate the custom property. For sizing, percentages, grid tracks and the container do the work instead of fixed pixels.

Animating with transition and @property

A transition only fires when a value changes, so a bar needs a starting state. The simpler route for bars is a CSS keyframes rule that begins each one at zero height and runs once on load, with no script involved.

Pies and rings behave differently. An unregistered custom property is read as a plain token stream, so the browser swaps it instantly instead of sweeping between values. Registering it with @property and a typed syntax such as a percentage fixes that.

MDN’s own example animates a registered –progress percentage inside a linear-gradient() to fill a progress bar over 2.5 seconds.

@property reached Baseline newly available status in July 2024, according to MDN Web Docs (2026). Older browsers skip the registration and jump between the two values instead of sweeping.

Wrap the animation in a prefers-reduced-motion: no-preference query, so people who ask for less movement get the finished chart immediately.

Making charts fit any container

Percentage widths and heights get you most of the way. Grid tracks in fr units let bars share space evenly, and circular charts need a fixed aspect ratio.

Flavio Copes points out that aspect-ratio: 1 plus a width is all a conic-gradient pie needs to stay circular.

Mads Stoumann’s DEV Community demos control responsiveness through the size of the chart’s container, wrapped in a custom element. The same CSS then shows fewer columns as the container shrinks (his demo uses small and extra-small tiers), without page-level breakpoints.

Container sizing leaves media queries for the cases that depend on the viewport, such as hiding secondary axis labels on a narrow screen.

How to make a CSS chart accessible

Keep the data as real text and give every bar or slice at least 3:1 contrast against its neighbors. The chart also needs a name for assistive technology. A chart built from empty divs exposes nothing to a screen reader, so the text equivalent comes first.

Choose the text equivalent

When the chart is built on a table, the table is the text equivalent. A caption, header cells with scope and the values inside the td elements give a screen reader everything the picture shows.

Skip role=”img” on that container. The role hides the children from assistive technology, which throws the table away.

A div-based chart is a different story. The shapes carry no text, so the container gets role=”img” and an ARIA label that states the finding, with the values printed nearby in a table or list.

<figure>
  <div class="pie" role="img"
      aria-label="Sales by region: North 20%, South 45%, West 35%"></div>
    <figcaption>Sales by region</figcaption>
  </figure>

“Pie chart” tells a listener nothing. The label should carry the numbers.

Meet the contrast threshold

WCAG 2.2 success criterion 1.4.11 sets a contrast ratio of at least 3:1 for graphical objects against adjacent colors (W3C).

W3C’s own failing example pairs a yellow fill (#FFF000) with white at 1.2:1.

The rule has an exit. Cornell’s web accessibility review guidance exempts pie slices when percentages or other supplemental information sit alongside them, so a value printed on each slice removes the requirement.

Without printed values, test every slice against the slice next to it, not only against the page background. An analyzer for color contrast does this in seconds.

Label bars and slices directly, and keep the legend order the same as the visual order. Varying pattern as well as hue helps too, for example with repeating-linear-gradient() fills.

Charts.css or hand-written CSS: which one fits?

Use Charts.css when a page needs several table-driven charts of the standard types, and write the CSS by hand for one or two charts or for any shape the framework does not ship. The framework pays off through repetition, not through any single chart.

What the framework adds

The license is MIT and there are no dependencies. Markup is a table with the charts-css class plus a type class such as column or bar.

Modifier classes handle axes, data spacing and direction, for example show-primary-axis, data-spacing-4 and reverse-data. Variants cover multi-dataset charts, percentage columns, stacked columns, pie and donut charts, and 3D effects (chartscss.org). A legend is just a list carrying the charts-css and legend classes (InfoQ, 2021).

Because the data sits in a real table, the framework inherits what accessible tables already provide: a caption, header cells and values that stay readable without the stylesheet.

Circular charts are covered too. The chartscss.org home page lists pie and donut charts, and its Pie page documents a table-based pie that takes –start and –end values in place of –size.

Size and upkeep, where the sources disagree

File size is reported two ways. The chartscss.org home page says 76kb with 7kb gzipped, and the GitHub README says 58.4kb minified and 5.9kb gzipped.

Neither page says whether the gap comes from release versions or from how the files were measured. The minified file of version 1.2.0, the current release from July 2025, weighs about 60kb and just under 6kb gzipped, so the README is the closer of the two.

The repository shows 272 commits, 30 open issues and 6 open pull requests (GitHub, 2026). Read the issue tracker for the chart type you need before committing to it.

The project docs make one performance claim. With CSS there is no rendering step, which gives an edge over JavaScript solutions.

Pros and cons

On the plus side, there are no dependencies and no script, the numbers stay readable as table text, and one class vocabulary keeps many charts consistent. The MIT license is a bonus.

The downsides are real, though. Only the standard chart types are covered, and the docs send complex charts with special behaviors to JavaScript tools. One td per data point makes the markup long for big series. And any look beyond the defaults still means writing your own custom properties and selectors.

For one or two charts, the hand-written patterns above are lighter than learning a class vocabulary. Once the same chart type repeats across many pages, the framework saves the repeated work.

How do CSS charts compare with SVG, canvas and JavaScript chart libraries?

CSS charts fit a handful of static charts in standard shapes. SVG is better for lines and curves. Large datasets belong on canvas, and anything users need to interact with usually ends up in a JavaScript library such as Chart.js, D3.js or Highcharts.

OptionDrawn withScreen reader textBest fit
CSS chartsHTML elements and CSSBuilt in when based on a tableA few static, standard charts
SVGVector shapes in the DOMAdded with titles and labelsLines, curves, custom shapes
Canvas (Chart.js)Pixels on a canvas elementNone until ARIA or fallback content is addedLarge datasets, heavy animation
HighchartsScript-generated SVGVaries, test with a screen readerMany chart types with vendor support

The Chart.js accessibility documentation states that canvas content is not accessible to screen readers, so the author adds ARIA attributes or fallback content.

Put that next to the earlier point about gradient backgrounds and a pattern appears. Canvas charts and gradient-drawn charts share the same flaw, because the drawing carries no text. Table-based CSS charts are the exception.

For a line chart, SVG is the better tool. A polyline takes a list of points, where CSS needs clip-path polygons.

Cost differs too. A CSS chart costs nothing, while Highcharts is free only for personal, non-commercial and educational use, and commercial use needs a paid license (Highcharts license terms).

The less obvious pick is often the right one. A page with three static charts does not need a charting library, because the download and the script execution buy nothing the CSS version lacks.

Where do CSS charts stop working?

CSS cannot find the largest value among sibling elements, so the maximum is written by hand or by a build step. That’s the first wall.

Dataset size is the next one. Older Lighthouse versions warned when the body held more than about 800 nodes and errored above about 1,400. Since Lighthouse 13 the check is an Optimize DOM size insight that flags a large DOM when style and layout work gets slow (Chrome for Developers). A 365-day column chart with a bar and a label per day adds 730 nodes before any wrapper element.

Interaction is limited as well. Hover states and basic tooltips work in CSS, but zoom, brushing and selection do not.

The last limit is any shape that depends on neighboring values, which the next section covers.

Line charts, area charts and running totals

A line segment needs the value of the point before it. CSS cannot read a sibling’s value, so the author writes the previous value into each point, or a script does.

Pie slices hit the same wall. In his DEV Community walkthrough, Mads Stoumann says he could not get CSS counters to produce the accumulated value and total behind his pie chart.

When two or more of these limits apply to the same chart, move it to SVG or a JavaScript library instead of stretching the CSS.

CSS Charts FAQ

Can CSS charts show tooltips on hover?

Yes, for simple labels. A :hover or :focus rule can reveal text from a data attribute through content: attr(data-label) on a pseudo-element. Add tabindex so keyboard users get the same label. Tooltips that follow the cursor or stay pinned need JavaScript.

How do you add a legend to a CSS chart?

Place a list beside the chart and give each item a colored marker that matches its bar or slice. Charts.css ships a legend list with the charts-css and legend classes. The legend also gives low-vision readers a text label for every color.

Can CSS charts display live data?

Yes, with a small script. CSS cannot fetch data, but JavaScript can write each new number into a custom property and the chart redraws itself. Keith Clark’s pie tutorial updates charts by calling setProperty() on the element’s style object.

Do CSS charts work in React, Vue and other frameworks?

Yes. A CSS chart is plain markup plus style rules, so any framework that renders HTML can output it. Charts.css is a stylesheet applied to table elements, which means it needs no wrapper component or special binding.

Which CSS Chart to Build First

Start with a bar chart. It takes one grid and one custom property per value, with no gradient arithmetic. Pie and ring charts come second, once the data binding is proven.

On real data, the order that holds up is a bar chart with a table or text equivalent, then a screen reader and contrast check. Pie or ring charts follow, and animation goes last.

Accessibility sits before animation because a failed contrast ratio forces new colors across every bar, while animation changes no text.

Test the first chart with the largest and smallest real values, not sample numbers, and with a screen reader running.

One dated condition would change the data-binding advice. As of October 2026, Chrome, Edge and Firefox 155+ support typed attr(), but stable Safari still lacks it. Once Safari ships it, a data attribute can replace the inline custom property.

When one chart passes both checks, several of them fit together in a CSS dashboard.