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.
<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.
<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.
<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.
<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.
| 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.
| 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 byaria-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-dashedand other pattern differences. -
Values printed inside bars may not clear AA. See
above —
contrast-color()improves it and does not guarantee it..chart-bar-valueoutside the bar always does. -
data-labelanddata-valueare generated content. Like every::beforetext 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:hoverbrightens and.chart-dot-activemarks 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.