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.
| 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 |
| 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.
| 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 |
| 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.
| 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.
| 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 |
<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".
| 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.
| 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-1is a.card. The card also handles its header, its media slot and its print appearance. -
Not with a literal colour beside them. Mixing
.text-mutedwith an inline#666gives 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-fullon a rectangle. It is 999px, so on a wide element the ends go to semicircles. It is for a pill or a circle.