Colour and surface

Everything that decides what a box looks like: its background, its text colour, its border, its corners and its shadow. Not one of these classes contains a literal colour — every one reads a token — which is why changing --hue-brand retints the whole page and why all of them already work in dark mode.

Background

The three neutral backgrounds are a depth order, not a palette: .bg-sunken is behind the page, --bg is the page, and .bg-surface is a card lifted off it. In dark mode the order inverts in lightness and stays the same in meaning, which is the point of naming them by role.

.bg-brand and .bg-soft also set a foreground colour, because a background without a matching text colour is how you get grey-on-brand that nobody can read. .bg-soft is the tinted-but-quiet one — the fill behind a selected row or an informational callout.

Background utilities
Class Declares Resolves to Source
.bg-surface background: var(--surface) background: light-dark(var(--ink-0), var(--ink-950)) 09-utilities.css:141
.bg-sunken background: var(--bg-sunken) background: light-dark(var(--ink-100), oklch(8.6% .012 var(--hue-neutral))) 09-utilities.css:142
.bg-soft background: var(--brand-soft); color: var(--brand-soft-text) background: light-dark(var(--brand-50), oklch(26% .05 var(--hue-brand))); color: light-dark(var(--brand-800), var(--brand-200)) 09-utilities.css:144
.bg-brand background: var(--brand-600); color: var(--text-on-brand) background: oklch(51.6% var(--chroma-brand) var(--hue-brand)); color: oklch(99% .01 var(--hue-brand)) 09-utilities.css:143
.bg-none background: none — 09-utilities.css:145
Design tokens this component reads
Token Live Declared value
--bg light-dark(var(--ink-50), var(--ink-1000))
--bg-sunken light-dark(var(--ink-100), oklch(8.6% .012 var(--hue-neutral)))
--surface light-dark(var(--ink-0), var(--ink-950))
--surface-2 light-dark(var(--ink-25), var(--ink-900))
--surface-hover light-dark(var(--ink-50), var(--ink-900))
--brand-soft light-dark(var(--brand-50), oklch(26% .05 var(--hue-brand)))

Text colour

Three neutrals and four status colours. The neutrals are a hierarchy — --text for the content, .text-muted for a caption or a label, .text-faint for metadata that should be findable and not read — and the gap between them is deliberately large enough to see and small enough to keep the faint one above the contrast floor.

The status four are the 700 step of each hue rather than the 500, so they carry enough contrast on a light surface to be used as text rather than only as a fill. .text-inherit is the escape hatch: it puts a link or an icon back to whatever colour its parent is, which is what you want for a link inside a coloured alert.

Text colour utilities
Class Declares Resolves to Source
.text-brand color: var(--brand) color: light-dark(var(--brand-600), var(--brand-400)) 09-utilities.css:112
.text-muted color: var(--text-muted) color: light-dark(var(--ink-600), var(--ink-400)) 09-utilities.css:110
.text-faint color: var(--text-faint) color: light-dark(var(--ink-500), var(--ink-500)) 09-utilities.css:111
.text-good color: var(--good-700) color: oklch(44% var(--chroma-status) var(--hue-good)) 09-utilities.css:113
.text-warn color: var(--warn-700) color: oklch(50% var(--chroma-status) var(--hue-warn)) 09-utilities.css:114
.text-bad color: var(--bad-700) color: oklch(44% calc(var(--chroma-status) * 1.1) var(--hue-bad)) 09-utilities.css:115
.text-inherit color: inherit — 09-utilities.css:116
Design tokens this component reads
Token Live Declared value
--text light-dark(var(--ink-900), var(--ink-100))
--text-muted light-dark(var(--ink-600), var(--ink-400))
--text-faint light-dark(var(--ink-500), var(--ink-500))
--brand light-dark(var(--brand-600), var(--brand-400))
--good-700 oklch(44% var(--chroma-status) var(--hue-good))
--warn-700 oklch(50% var(--chroma-status) var(--hue-warn))
--bad-700 oklch(44% calc(var(--chroma-status) * 1.1) var(--hue-bad))

Colour is never the only signal in Deck. .alert-bad has an icon, .badge-good has a word, and a chart series has a shape as well as a hue — because roughly one man in twelve cannot separate the good and bad ends of a red-green pair. If you use .text-bad to mean "this failed", something else on the row has to say so too.

Border

One hairline, in --line, which is a low-contrast neutral that reads as a division rather than as a drawn box. .border-t and .border-b set the block edges and .bis and .bie the inline ones — the inline pair is named logically because those are the two that flip.

Border utilities
Class Declares Resolves to Source
.border border: 1px solid var(--line) border: 1px solid light-dark(var(--ink-200), oklch(28% .016 var(--hue-neutral))) 09-utilities.css:147
.border-0 border: 0 — 09-utilities.css:151
.border-t border-block-start: 1px solid var(--line) border-block-start: 1px solid light-dark(var(--ink-200), oklch(28% .016 var(--hue-neutral))) 09-utilities.css:148
.border-b border-block-end: 1px solid var(--line) border-block-end: 1px solid light-dark(var(--ink-200), oklch(28% .016 var(--hue-neutral))) 09-utilities.css:149
.bis border-inline-start: 1px solid var(--line) border-inline-start: 1px solid light-dark(var(--ink-200), oklch(28% .016 var(--hue-neutral))) 19-logical.css:71
.bie border-inline-end: 1px solid var(--line) border-inline-end: 1px solid light-dark(var(--ink-200), oklch(28% .016 var(--hue-neutral))) 19-logical.css:72
.border-brand border-color: var(--brand-400) border-color: oklch(70.2% calc(var(--chroma-brand) * .90) var(--hue-brand)) 09-utilities.css:150

Radius

Five steps and two logical halves. The steps are in px rather than rem on purpose: a corner radius is a physical property of the shape and should not grow when someone raises their base font size, or a 4px chip corner becomes a 5px one for no reason anybody asked for.

.r-start and .r-end round one inline side, which is how a segmented control or a button group gets rounded ends and square joins that survive a direction flip.

Border radius utilities
Class Declares Resolves to Source
.r-xs border-radius: var(--r-xs) border-radius: 4px 09-utilities.css:153
.r-sm border-radius: var(--r-sm) border-radius: 7px 09-utilities.css:154
.r-md border-radius: var(--r-md) border-radius: 12px 09-utilities.css:155
.r-lg border-radius: var(--r-lg) border-radius: 18px 09-utilities.css:156
.r-full border-radius: var(--r-full) border-radius: 999px 09-utilities.css:157
.r-start border-start-start-radius: var(--r-md); border-end-start-radius: var(--r-md) border-start-start-radius: 12px; border-end-start-radius: 12px 19-logical.css:82
.r-end border-start-end-radius: var(--r-md); border-end-end-radius: var(--r-md) border-start-end-radius: 12px; border-end-end-radius: 12px 19-logical.css:83
The five steps, on the same element
r-xs r-sm r-md r-lg r-full
<div class="cluster cluster-tight"> <span class="badge r-xs">r-xs</span> <span class="badge r-sm">r-sm</span> <span class="badge r-md">r-md</span> <span class="badge r-lg">r-lg</span> <span class="badge r-full">r-full</span> </div>
<div class="cluster cluster-tight">
  <span class="badge r-xs">r-xs</span>
  <span class="badge r-sm">r-sm</span>
  <span class="badge r-md">r-md</span>
  <span class="badge r-lg">r-lg</span>
  <span class="badge r-full">r-full</span>
</div>

Shadow

Four elevations and a reset. The shadow colour is a token, --shadow-color, rather than a flat black at low alpha — a black shadow over a tinted surface goes muddy, and tinting the shadow toward the surface keeps it reading as depth.

Elevation should track how far something is from the page, so a card is .shadow-1, a popover is .shadow-2, and .shadow-4 is for something being dragged. Using a large shadow on a resting element spends the whole range at once and leaves nothing to say "this is lifted".

Box shadow utilities
Class Declares Resolves to Source
.shadow-0 box-shadow: none — 09-utilities.css:159
.shadow-1 box-shadow: var(--shadow-1) box-shadow: 0 1px 2px oklch(20% .03 var(--shadow-color) / .06), 0 1px 1px oklch(20% .03 var(--shadow-color) / .04) 09-utilities.css:160
.shadow-2 box-shadow: var(--shadow-2) box-shadow: 0 2px 4px oklch(20% .03 var(--shadow-color) / .05), 0 4px 12px oklch(20% .03 var(--shadow-color) / .07) 09-utilities.css:161
.shadow-3 box-shadow: var(--shadow-3) box-shadow: 0 4px 8px oklch(20% .03 var(--shadow-color) / .06), 0 12px 28px oklch(20% .03 var(--shadow-color) / .10) 09-utilities.css:162
.shadow-4 box-shadow: var(--shadow-4) box-shadow: 0 8px 16px oklch(20% .03 var(--shadow-color) / .08), 0 24px 56px oklch(20% .03 var(--shadow-color) / .14) 09-utilities.css:163

Tone

Three classes driven by a style query rather than by their own name. Set --tone to critical, caution or clear on an element carrying .cq-tone, and every .tone-surface, .tone-text and .tone-icon inside it takes the matching colour — with no class changes on the children at all.

<article class="card cq-tone" style="--tone: caution">
  <div class="card-body tone-surface">
    <h3 class="tone-text">Payment overdue</h3>
  </div>
</article>

That matters when the tone comes from data. A server rendering a row does not have to map a status onto three class names; it writes one custom property and the stylesheet does the rest. Style queries are supported in current Chrome and Safari; where they are not, the fallback is simply the untinted appearance, which is why the rows below show a condition rather than a plain declaration.

Style-query tone utilities
Class Declares Resolves to Source
.tone-surface background: var(--bad-100); border-color: color-mix(in oklab, var(--bad-500) 35%, transparent) background: oklch(94.5% calc(var(--chroma-status) * .3) var(--hue-bad)); border-color: color-mix(in oklab, oklch(58% calc(var(--chroma-status) * 1.25) var(--hue-bad)) 35%, transparent) 18-container.css:185
.tone-text color: var(--bad-700) color: oklch(44% calc(var(--chroma-status) * 1.1) var(--hue-bad)) 18-container.css:186
.tone-icon color: var(--bad-500) color: oklch(58% calc(var(--chroma-status) * 1.25) var(--hue-bad)) 18-container.css:187

When not to use these

  • Not to build a component that already exists. .bg-surface .border .r-md .shadow-1 is a .card. The card also handles its header, its media slot and its print appearance.
  • Not with a literal colour beside them. Mixing .text-muted with an inline #666 gives you an element that half-follows the theme, which is more confusing than one that does not follow it at all.
  • Not as the only signal for a state. Pair a status colour with an icon or a word.
  • Not .r-full on a rectangle. It is 999px, so on a wide element the ends go to semicircles. It is for a pill or a circle.