Charts

There is no charting library. Bars, columns, donuts and heatmaps are plain CSS driven by a --value custom property; lines and areas are inline SVG you write and Deck styles. Everything reads the brand palette, so a chart rethemes with the rest of the application rather than carrying its own colours.

When to use it

Use these for the charts an application actually needs: a bar per month, a donut of three segments, a sparkline in a table cell. They are markup you render on the server, which means no client-side data layer, no library to load, and a chart that appears with the page rather than after it.

For interactive analysis — zooming, brushing, tooltips on a thousand points, axes computed from data — use a real charting library. That is a different job, and Deck does not pretend to do it.

--value is the whole idea

A column's height is calc(var(--value) * 1%). A bar's width is the same on the inline axis. A donut segment and a heatmap cell read the same property. So rendering a chart is writing numbers into style attributes, which any template language does:

<div class="chart-col" style="--value:68" data-label="Mar" data-value="68"></div>

data-label and data-value are drawn as ::after and ::before content, so the axis label and the value on top of the bar come from attributes rather than from extra elements.

Six numbers in six style attributes
Exports per month 2026
<figure class="chart"> <figcaption class="chart-head"> <span class="chart-title">Exports per month</span> <span class="chart-note">2026</span> </figcaption> <div class="chart-columns"> <div class="chart-col" style="--value:42" data-label="Jan" data-value="42"></div> <div class="chart-col" style="--value:58" data-label="Feb" data-value="58"></div> <div class="chart-col" style="--value:68" data-label="Mar" data-value="68"></div> <div class="chart-col" style="--value:51" data-label="Apr" data-value="51"></div> <div class="chart-col" style="--value:74" data-label="May" data-value="74"></div> <div class="chart-col" style="--value:63" data-label="Jun" data-value="63"></div> </div> </figure>
<figure class="chart">
  <figcaption class="chart-head">
    <span class="chart-title">Exports per month</span>
    <span class="chart-note">2026</span>
  </figcaption>
  <div class="chart-columns">
    <div class="chart-col" style="--value:42" data-label="Jan" data-value="42"></div>
    <div class="chart-col" style="--value:58" data-label="Feb" data-value="58"></div>
    <div class="chart-col" style="--value:68" data-label="Mar" data-value="68"></div>
    <div class="chart-col" style="--value:51" data-label="Apr" data-value="51"></div>
    <div class="chart-col" style="--value:74" data-label="May" data-value="74"></div>
    <div class="chart-col" style="--value:63" data-label="Jun" data-value="63"></div>
  </div>
</figure>

The palette

.chart declares six series colours as custom properties, and five of them are computed from the brand hue:

--c1: var(--brand-500);
--c2: oklch(64% .13 calc(var(--hue-brand) + 48));
--c3: oklch(70% .14 calc(var(--hue-brand) + 96));
--c4: oklch(62% .13 calc(var(--hue-brand) - 52));
--c5: oklch(74% .12 calc(var(--hue-brand) + 148));
--c6: oklch(56% .10 calc(var(--hue-brand) + 200));

Rotating the hue and holding lightness and chroma roughly steady is what makes six colours that are distinguishable from each other and obviously related. Change --hue-brand and the whole palette rotates with it — the charts stay in the same family as the buttons.

.s1 through .s6 set --series to one of them, and .s-good, .s-warn, .s-bad and .s-muted set it to a status colour. Put the class on a mark and its fill, its stroke and its legend swatch all follow.

One class per series, on the mark and the legend alike
Shipped Pending Failed
<figure class="chart chart-sm"> <div class="chart-legend"> <span class="s1">Shipped</span> <span class="s2">Pending</span> <span class="s-bad">Failed</span> </div> <div class="chart-columns"> <div class="chart-col s1" style="--value:72" data-label="Jan"></div> <div class="chart-col s2" style="--value:48" data-label="Feb"></div> <div class="chart-col s-bad" style="--value:22" data-label="Mar"></div> </div> </figure>
<figure class="chart chart-sm">
  <div class="chart-legend">
    <span class="s1">Shipped</span>
    <span class="s2">Pending</span>
    <span class="s-bad">Failed</span>
  </div>
  <div class="chart-columns">
    <div class="chart-col s1" style="--value:72" data-label="Jan"></div>
    <div class="chart-col s2" style="--value:48" data-label="Feb"></div>
    <div class="chart-col s-bad" style="--value:22" data-label="Mar"></div>
  </div>
</figure>

Text on a series colour

A value printed inside a bar has to be readable against whatever colour that series is, and the series colours are chosen for discriminability rather than for contrast. Where the browser supports it, Deck uses contrast-color():

@supports (color: contrast-color(red)) {
  .chart-col, .chart-bar-fill, .chart-meter > span {
    color: contrast-color(var(--series, var(--c1)));
  }
}

The source comment is unusually candid about the limits, and it is worth repeating: some series colours sit in the 55–65% lightness band where neither black nor white clears AA comfortably, so this raises the floor rather than guaranteeing it. If a value must be readable, put it outside the bar with .chart-bar-value rather than on top of it.

The chart types

Horizontal bars

.chart-bars holds rows of .chart-bar, each a .chart-bar-label, a .chart-bar-track containing a .chart-bar-fill, and a .chart-bar-value. Better than columns when the labels are words rather than dates.

The value sits outside the bar, where it is always readable
Chrome 64%
Safari 21%
Firefox 9%
<figure class="chart"> <div class="chart-bars"> <div class="chart-bar"> <span class="chart-bar-label">Chrome</span> <span class="chart-bar-track"><span class="chart-bar-fill s1" style="--value:64"></span></span> <span class="chart-bar-value">64%</span> </div> <div class="chart-bar"> <span class="chart-bar-label">Safari</span> <span class="chart-bar-track"><span class="chart-bar-fill s2" style="--value:21"></span></span> <span class="chart-bar-value">21%</span> </div> <div class="chart-bar"> <span class="chart-bar-label">Firefox</span> <span class="chart-bar-track"><span class="chart-bar-fill s3" style="--value:9"></span></span> <span class="chart-bar-value">9%</span> </div> </div> </figure>
<figure class="chart">
  <div class="chart-bars">
    <div class="chart-bar">
      <span class="chart-bar-label">Chrome</span>
      <span class="chart-bar-track"><span class="chart-bar-fill s1" style="--value:64"></span></span>
      <span class="chart-bar-value">64%</span>
    </div>
    <div class="chart-bar">
      <span class="chart-bar-label">Safari</span>
      <span class="chart-bar-track"><span class="chart-bar-fill s2" style="--value:21"></span></span>
      <span class="chart-bar-value">21%</span>
    </div>
    <div class="chart-bar">
      <span class="chart-bar-label">Firefox</span>
      <span class="chart-bar-track"><span class="chart-bar-fill s3" style="--value:9"></span></span>
      <span class="chart-bar-value">9%</span>
    </div>
  </div>
</figure>

Donut

.donut is a conic gradient driven by --value, with .donut-center holding a figure in the hole. One element, no SVG.

role="img" with a label, because the shape carries the meaning
<div class="donut-wrap"> <div class="donut" style="--value:68" role="img" aria-label="68 per cent of quota used"> <div class="donut-center"> <span class="donut-value">68%</span> <span class="donut-label">of quota</span> </div> </div> </div>
<div class="donut-wrap">
  <div class="donut" style="--value:68" role="img" aria-label="68 per cent of quota used">
    <div class="donut-center">
      <span class="donut-value">68%</span>
      <span class="donut-label">of quota</span>
    </div>
  </div>
</div>

Heatmap

.chart-heat is a grid of --cols columns whose cells mix the series colour with the sunken background by --value — so a value of 0 is the background and 100 is the full colour, with everything between interpolated in oklab.

<div class="chart-heat" style="--cols:7;max-inline-size:16rem"> <span style="--value:10"></span><span style="--value:35"></span><span style="--value:80"></span> <span style="--value:55"></span><span style="--value:5"></span><span style="--value:95"></span> <span style="--value:45"></span><span style="--value:70"></span><span style="--value:20"></span> <span style="--value:60"></span><span style="--value:88"></span><span style="--value:30"></span> <span style="--value:15"></span><span style="--value:50"></span> </div>
<div class="chart-heat" style="--cols:7;max-inline-size:16rem">
  <span style="--value:10"></span><span style="--value:35"></span><span style="--value:80"></span>
  <span style="--value:55"></span><span style="--value:5"></span><span style="--value:95"></span>
  <span style="--value:45"></span><span style="--value:70"></span><span style="--value:20"></span>
  <span style="--value:60"></span><span style="--value:88"></span><span style="--value:30"></span>
  <span style="--value:15"></span><span style="--value:50"></span>
</div>

Lines and areas

These are the one place you write SVG. .chart-svg is the element, .chart-line the stroke, .chart-area the fill beneath it, .chart-dot a point and .chart-gridline the grid. All take --series, so .s2 on a path colours it like everything else.

.chart-area-g from src/20-gradients.css fills the area with a fading gradient rather than a flat tint, which reads better under a line. .chart-line-dashed is the convention for a forecast or a target.

Sparklines and meters

.sparkline is a tiny inline chart for a table cell or a stat, with .sparkline-good and .sparkline-bad for direction. .chart-meter is a single horizontal bar — a quota, a score — and is the simplest thing in the file.

Classes

Generated from src/14-charts.css by tools/docs/extract.mjs.

Classes, generated from the stylesheet source
Class Layer What it does Source
.chart deck.components The frame, and the palette. Six series colours are computed by rotating --hue-brand, so charts retheme with the rest of the app and stay in one family. That also makes them hard to tell apart for a colour-blind reader — label the marks directly rather than relying on hue. 14-charts.css:15
.chart-area deck.components Sets fill, opacity, stroke, animation. 14-charts.css:210
.chart-area-g deck.effects Area charts read better with a gradient falling off to nothing. 20-gradients.css:283
.chart-bar deck.components Sets display, grid-template-columns, align-items, gap, and 1 more. 14-charts.css:154
.chart-bar-fill deck.components The filled part of a horizontal bar, sized from --value. It grows from the starting edge, which needs transform-origin — a physical property — flipped for RTL in 19-logical.css. 14-charts.css:171
.chart-bar-label deck.components Sets color, overflow, text-overflow, white-space. 14-charts.css:161
.chart-bar-track deck.components Sets block-size, border-radius, background, overflow. 14-charts.css:162
.chart-bar-value deck.components The value printed beside a bar rather than inside it. Prefer this over text on the fill: contrast-color() raises the floor for text on a series colour but does not guarantee AA. 14-charts.css:183
.chart-bars deck.components Sets display, flex-direction, gap. 14-charts.css:153
.chart-baseline deck.components Sets stroke, stroke-width, vector-effect. 14-charts.css:231
.chart-col deck.components A column. Its height is calc(var(--value) * 1%), so rendering a chart is writing numbers into style attributes. data-label and data-value are drawn as generated content, which means they are for sighted readers only. 14-charts.css:74
.chart-col-stack deck.components Stacked columns: nest segments inside a .chart-col-stack 14-charts.css:136
.chart-columns deck.components <div class="chart-columns"> <div class="chart-col" style="--value:68" data-label="Mar" data-value="68"></div> 14-charts.css:56
.chart-dot deck.components Sets fill, stroke, stroke-width, vector-effect. 14-charts.css:218
.chart-dot-active deck.components Sets fill. 14-charts.css:224
.chart-gridline deck.components Sets stroke, stroke-width, vector-effect. 14-charts.css:226
.chart-group deck.components Grouped columns 14-charts.css:149
.chart-head deck.components Sets display, align-items, gap, flex-wrap. 14-charts.css:32
.chart-heat deck.components A grid of --cols columns whose cells mix the series colour with the sunken background by --value, interpolated in oklab. 14-charts.css:311
.chart-legend deck.components The key. Built from real buttons it gets a working toggle state for free, because the faded style keys off [aria-pressed="false"]. 14-charts.css:39
.chart-lg deck.components Sets --chart-h. 14-charts.css:30
.chart-line deck.components Sets fill, stroke, stroke-width, stroke-linecap, and 5 more. 14-charts.css:196
.chart-line-dashed deck.components Sets stroke-dasharray, animation, stroke-dashoffset, opacity. 14-charts.css:208
.chart-meter deck.components Sets display, block-size, border-radius, overflow, and 1 more. 14-charts.css:294
.chart-note deck.components Sets font-size, color. 14-charts.css:34
.chart-plot deck.components Sets display, grid-template-columns. 14-charts.css:252
.chart-sm deck.components Sets --chart-h. 14-charts.css:29
.chart-svg deck.components <svg class="chart-svg" viewBox="0 0 300 100" preserveAspectRatio="none"> <path class="chart-area" d="..."/> <path class="chart-line" d="..."/> 14-charts.css:191
.chart-title deck.components Sets font-size, font-weight. 14-charts.css:33
.chart-x deck.components Sets display, justify-content, font-size, color, and 1 more. 14-charts.css:234
.chart-y deck.components Sets display, flex-direction, justify-content, font-size, and 5 more. 14-charts.css:241
.donut deck.components A conic gradient driven by --value, with .donut-center holding a figure in the hole. One element, no SVG. Give it role="img" and a label — the shape carries the meaning and nothing else conveys it. 14-charts.css:270
.donut-center deck.components Sets position, display, place-items, text-align, and 1 more. 14-charts.css:283
.donut-label deck.components No declarations. 14-charts.css:291
.donut-value deck.components No declarations. 14-charts.css:290
.donut-wrap deck.components Sets position, display, place-items. 14-charts.css:282
.sparkline deck.components A tiny inline chart for a table cell or a stat block. 14-charts.css:256
.sparkline-bad deck.components No declarations. 14-charts.css:264
.sparkline-good deck.components No declarations. 14-charts.css:263
.s1 deck.components Series 1. Every chart mark reads --series, so a colour is set once on the element and the fill, stroke and legend swatch all follow. 14-charts.css:322
.s2 deck.components Sets --series. 14-charts.css:323
.s3 deck.components Sets --series. 14-charts.css:324
.s4 deck.components Sets --series. 14-charts.css:325
.s5 deck.components Sets --series. 14-charts.css:326
.s6 deck.components Sets --series. 14-charts.css:327
.s-good deck.components Sets --series. 14-charts.css:328
.s-warn deck.components Sets --series. 14-charts.css:329
.s-bad deck.components Sets --series. 14-charts.css:330
.s-muted deck.components Sets --series. 14-charts.css:331

Tokens

The six series colours and --chart-h are local to .chart rather than global tokens, because they are a chart's palette rather than the application's. --hue-brand is what they are all computed from.

Design tokens this component reads
Token Live Declared value
--hue-brand 196
--brand-500 oklch(60.8% var(--chroma-brand) var(--hue-brand))
--good-500 oklch(62% var(--chroma-status) var(--hue-good))
--warn-500 oklch(76% var(--chroma-status) var(--hue-warn))
--bad-500 oklch(58% calc(var(--chroma-status) * 1.25) var(--hue-bad))
--line light-dark(var(--ink-200), oklch(28% .016 var(--hue-neutral)))
--bg-sunken light-dark(var(--ink-100), oklch(8.6% .012 var(--hue-neutral)))
--text-muted light-dark(var(--ink-600), var(--ink-400))
--text-xs .75rem
--space-2 .5rem
--space-3 .75rem

Accessibility

  • A chart made of <div>s is invisible. None of these classes carry semantics. A screen-reader user gets nothing at all unless you supply it — this is the single most important thing on the page.
  • The best answer is usually a table. Put the figures in a <table class="sr-only"> beside the chart, or make the chart the visual layer over a real one. Then the data is available, sortable and printable, and the chart is decoration on top.
  • The next best is role="img" with a summary. A donut showing one number is well served by aria-label="68 per cent of quota used", as in the example. A six-series line chart is not — no label summarises it usefully.
  • Never rely on colour alone to distinguish series. Six hues at similar lightness are hard to tell apart for a reader with colour vision deficiency — and the palette is deliberately hue-rotated, which is precisely the axis that is hardest to distinguish. Label the marks directly, or use .chart-line-dashed and other pattern differences.
  • Values printed inside bars may not clear AA. See above — contrast-color() improves it and does not guarantee it. .chart-bar-value outside the bar always does.
  • data-label and data-value are generated content. Like every ::before text in Deck, they are not reliably announced. They are for sighted readers; the accessible version has to exist separately.
  • The legend can be interactive. .chart-legend [aria-pressed="false"] fades a toggled-off series, so a legend of real <button>s gets a working state for free — with the accessible state driving the styling, as elsewhere in Deck.
  • Hover-only detail is lost on touch and keyboard. .chart-col:hover brightens and .chart-dot-active marks a point; neither is reachable without a pointer.

Right to left

Bars grow from the starting edge, which means transform-origin has to be flipped for RTL — and transform-origin has no logical form. src/19-logical.css handles it in one rule shared with the toast timer and the progress fill:

[dir="rtl"] :is(.chart-bar-fill, .progress::-webkit-progress-value, .toast-timer) {
  transform-origin: right center;
}

Columns grow on the block axis and are unaffected. A donut's conic gradient does not mirror either, which is correct — a clock face reads the same way in every writing direction.

Reduced motion

Bars, columns, lines, areas and donuts all animate in when they first render. Under prefers-reduced-motion: reduce, src/14-charts.css sets animation: none on all of them explicitly rather than relying on the global reset:

.chart-col, .chart-col-stack, .chart-bar-fill, .chart-line, .chart-area, .donut { animation: none; }

That is the right treatment. The global reset shortens animations to .01ms, which for a chart that animates from zero would risk a frame showing an empty chart. Setting none means the chart is simply drawn at its final values.

Printing

Ten rules. Chart fills are in the print-color-adjust: exact list — .chart-col, .chart-col-stack > span, .chart-bar-fill, .chart-meter > span, .donut and .chart-heat > * — because a chart whose backgrounds are stripped is a set of empty rectangles.

This is one of the places print support genuinely pays: reports go to PDF, and a printed dashboard with no bars in it is worse than no dashboard.

Overriding it

/* One chart, no layer needed */
<figure class="chart" style="--chart-h:180px;--c1:var(--good-500)">

@layer app.components {
  /* A categorical palette that is not hue-rotated from the brand */
  .chart-categorical {
    --c1: #4e79a7; --c2: #f28e2b; --c3: #e15759;
    --c4: #76b7b2; --c5: #59a14f; --c6: #edc948;
  }

  /* Thicker lines */
  .chart-line { stroke-width: 3; }
}

A hue-rotated palette is coherent and hard to tell apart; a hand-picked categorical one is the opposite. If a chart has more than three series and the reader must distinguish them, the second is the better trade.

When not to use it

  • Not without an accessible alternative. A table beside the chart, or role="img" with a real summary. Without one the chart does not exist for a portion of your readers.
  • Not for interactive analysis. Zoom, brush, crosshairs, tooltips on a thousand points — use a charting library. These classes render a picture of data you already decided to show.
  • Not for many points. One element per column means a hundred columns is a hundred elements. Past a few dozen, use SVG with a single <path>.
  • Not for two numbers. A donut showing 68% is a picture of a figure that would be clearer written down. Charts earn their space by showing shape or comparison.
  • Not with six series and colour as the only distinction. The palette is hue-rotated, and hue is the axis colour-blind readers lose. Label the marks directly.