Icon
.icon is sized in em, not pixels — so it scales with the
text it sits beside instead of needing a size for every context — and it is stroked in
currentColor, so it takes the colour of whatever it is inside. Those two
decisions are why an icon in a button, a muted caption and a danger alert all look
right without a variant for each.
When to use it
Put it on an <svg> containing a <use> that
references the sprite. That is the whole pattern — there is no icon font, no
per-icon class, and no build step that inlines anything.
Sits on the baseline of the text beside it
<p class="cluster cluster-tight">
<svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#check-circle"></use></svg>
Sits on the baseline of the text beside it
</p>
<button class="btn btn-primary">
<svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#download"></use></svg>
Download
</button>
<p class="cluster cluster-tight">
<svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#check-circle"></use></svg>
Sits on the baseline of the text beside it
</p>
<button class="btn btn-primary">
<svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#download"></use></svg>
Download
</button>
vertical-align: -.22em is the number that makes it land on the baseline
rather than floating above it. flex: 0 0 auto stops it shrinking when the
text beside it is long.
Sizes
| Class | Size | Use |
|---|---|---|
.icon-sm |
1em |
Beside small text. Uses the heavier cut of the sprite. |
.icon |
1.25em |
The default. Lines up with body text. |
.icon-lg |
1.5em |
A button that is mostly icon. |
.icon-xl |
2em |
An empty state, a feature row. |
<svg class="icon icon-sm" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-sm"></use></svg>
<svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star"></use></svg>
<svg class="icon icon-lg" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star"></use></svg>
<svg class="icon icon-xl" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star"></use></svg>
<svg class="icon icon-sm" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-sm"></use></svg>
<svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star"></use></svg>
<svg class="icon icon-lg" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star"></use></svg>
<svg class="icon icon-xl" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star"></use></svg>
Why .icon-sm needs a different symbol
The sprite's symbols are filled outlines rather than strokes, so
stroke-width does nothing to them — an icon shrunk to
1em would get visually lighter with no way to compensate.
So every icon ships twice: #name at weight 400 and
#name-sm at weight 500, generated from the variable font by
tools/icons/build-icons.mjs. .icon-sm is meant to be paired
with the -sm symbol.
CSS cannot rewrite a <use href>, so the pairing is on
you. .icon-sm with #star renders the light cut at a
small size and simply looks thin — nothing errors, and nothing warns. This is the one
part of Deck's icon system that a reader has to remember rather than being handed.
Fill and muted
.icon-fill swaps stroke for fill, for the solid cut of a glyph —
a filled star for a rating, a filled circle for a status.
.icon-muted drops the colour to --text-faint.
<svg class="icon icon-lg" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star"></use></svg>
<svg class="icon icon-lg icon-fill" style="color:var(--warn-500)" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg>
<svg class="icon icon-lg icon-muted" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star"></use></svg>
<svg class="icon icon-lg" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star"></use></svg>
<svg class="icon icon-lg icon-fill" style="color:var(--warn-500)" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg>
<svg class="icon icon-lg icon-muted" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star"></use></svg>
Icon tiles
.icon-tile is a rounded square with a tinted background, for a feature
row or an empty state — the icon equivalent of an
avatar. Three status variants and a large size.
<span class="icon-tile"><svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#folder"></use></svg></span>
<span class="icon-tile icon-tile-good"><svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#check"></use></svg></span>
<span class="icon-tile icon-tile-warn"><svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#alert-triangle"></use></svg></span>
<span class="icon-tile icon-tile-bad"><svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#x"></use></svg></span>
<span class="icon-tile icon-tile-lg"><svg class="icon icon-lg" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#upload"></use></svg></span>
<span class="icon-tile"><svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#folder"></use></svg></span>
<span class="icon-tile icon-tile-good"><svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#check"></use></svg></span>
<span class="icon-tile icon-tile-warn"><svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#alert-triangle"></use></svg></span>
<span class="icon-tile icon-tile-bad"><svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#x"></use></svg></span>
<span class="icon-tile icon-tile-lg"><svg class="icon icon-lg" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#upload"></use></svg></span>
.icon-spin and .icon-follow
.icon-spin rotates continuously — a loading state on an icon that is not
a .spinner. .icon-follow steps three pixels forward when its
parent is hovered, which is what makes the chevron in a
list row or a
card link feel like it is going somewhere.
<div class="list" style="max-inline-size:24rem">
<a class="list-row" href="#motion-classes">
<div class="list-main"><span class="list-title">Hover this row</span></div>
<svg class="icon icon-follow list-trail" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#chevron-right"></use></svg>
</a>
</div>
<span class="cluster cluster-tight">
<svg class="icon icon-spin" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#refresh"></use></svg>
Working
</span>
<div class="list" style="max-inline-size:24rem">
<a class="list-row" href="#motion-classes">
<div class="list-main"><span class="list-title">Hover this row</span></div>
<svg class="icon icon-follow list-trail" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#chevron-right"></use></svg>
</a>
</div>
<span class="cluster cluster-tight">
<svg class="icon icon-spin" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#refresh"></use></svg>
Working
</span>
Classes
Eleven files style an icon, which is what happens to a primitive used everywhere. Only two of them are about the icon itself; the rest position it inside another component.
| Class | Layer | What it does | Source |
|---|---|---|---|
.icon |
deck.mobile |
An SVG icon sized in em, so it scales with the text it sits beside and lands on the baseline. Icons are filled with currentColor and inherit the surrounding colour. | 08-mobile.css:134 |
.icon-fill |
deck.mobile |
Sets fill, stroke. | 08-mobile.css:156 |
.icon-follow |
deck.motion |
Sets transition. | 16-motion.css:254 |
.icon-lg |
deck.mobile |
A 1.6em icon, for icon tiles and empty states where the default would look undersized. | 08-mobile.css:154 |
.icon-muted |
deck.mobile |
Sets color. | 08-mobile.css:157 |
.icon-sm |
deck.mobile |
Small icons reference the heavier cut of the sprite, not a thicker stroke: the symbols are filled outlines, so stroke-width does nothing to them. Every icon ships twice, #name at wght 400 and #name-sm at wght 500, and .icon-sm goes with the -sm symbol. CSS cannot rewrite a <use href>, so the pairing lives in the markup: <svg class="icon icon-sm"><use href="deck-icons.svg#check-sm"></use></svg> | 08-mobile.css:151 |
.icon-spin |
deck.mobile |
Sets animation. | 08-mobile.css:158 |
.icon-tile |
deck.mobile |
Icon in a tinted tile — for feature rows and empty states | 08-mobile.css:161 |
.icon-tile-bad |
deck.mobile |
Sets background, color. | 08-mobile.css:173 |
.icon-tile-good |
deck.mobile |
Sets background, color. | 08-mobile.css:171 |
.icon-tile-lg |
deck.mobile |
Sets inline-size, block-size, border-radius. | 08-mobile.css:174 |
.icon-tile-warn |
deck.mobile |
Sets background, color. | 08-mobile.css:172 |
.icon-xl |
deck.mobile |
Sets inline-size, block-size, stroke-width. | 08-mobile.css:155 |
.is-open |
deck.components |
No declarations. | 11-combobox.css:73 |
Tokens
An icon reads almost nothing. It inherits color and
font-size from its parent, which is the entire design.
| Token | Live | Declared value |
|---|---|---|
--text-faint |
light-dark(var(--ink-500), var(--ink-500)) |
|
--brand-soft |
light-dark(var(--brand-50), oklch(26% .05 var(--hue-brand))) |
|
--brand-soft-text |
light-dark(var(--brand-800), var(--brand-200)) |
|
--good-100 |
oklch(94% calc(var(--chroma-status) * .3) var(--hue-good)) |
|
--warn-100 |
oklch(95% calc(var(--chroma-status) * .32) var(--hue-warn)) |
|
--bad-100 |
oklch(94.5% calc(var(--chroma-status) * .3) var(--hue-bad)) |
|
--r-md |
12px |
Accessibility
-
Nearly every icon should be
aria-hidden="true". An icon beside a label is decoration — the label is the name. Every example on this page hides its icons for that reason. -
An icon-only control needs a label on the control.
aria-labelon the<button>, not on the SVG. The button is what gets focus and what is announced. -
An icon that carries meaning on its own needs
role="img"and a title — a status glyph in a table cell with no text beside it, for instance. That is rare, and usually a sign the cell should have text. -
Colour is not a status. A green tick and a red cross are the same
shape to a screen reader and similar to some readers with colour vision deficiency.
The
.icon-tile-*variants are a tint on top of a glyph, not a substitute for words. -
currentColormeans contrast is inherited. An icon is as readable as the text around it, automatically — which is one of the quiet benefits of stroking rather than filling with a fixed colour. -
Icons scale with zoom because they are sized in
em. A pixel-sized icon stays put while the text around it grows, which at 200% looks broken. -
.icon-spinis exempt from the reduced-motion reset.src/02-reset.csskeeps it turning at 2.4 seconds rather than freezing it, on the grounds that a frozen loading indicator reads as a hung page.
Right to left
This is the most interesting rule in the component.
src/19-logical.css mirrors directional glyphs by matching on the sprite
id itself:
[dir="rtl"] :is(
.icon:has(use[href$="#chevron-left"]),
.icon:has(use[href$="#arrow-right"]),
.icon:has(use[href$="#send"]),
…
) { scale: -1 1; }
So an arrow, a chevron and the send glyph flip automatically, while a checkmark, a
clock and a magnifier — which would look wrong mirrored — are left alone. It is a
list rather than a rule, which means a directional icon you add yourself is
not in it; reach for .mirror-rtl in that case.
<div dir="rtl" class="cluster">
<svg class="icon icon-lg" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#chevron-right"></use></svg>
<svg class="icon icon-lg" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#send"></use></svg>
<svg class="icon icon-lg" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#check"></use></svg>
<svg class="icon icon-lg" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#clock"></use></svg>
</div>
<div dir="rtl" class="cluster">
<svg class="icon icon-lg" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#chevron-right"></use></svg>
<svg class="icon icon-lg" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#send"></use></svg>
<svg class="icon icon-lg" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#check"></use></svg>
<svg class="icon icon-lg" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#clock"></use></svg>
</div>
Reduced motion
.icon-follow is declared inside
@media (prefers-reduced-motion: no-preference), so a reader who has asked
for less motion never receives the rule at all. .icon-spin is the
opposite case: it is in the reset's exemption list and keeps moving, slowly, because a
frozen spinner reads as broken.
Printing
Icons print. They are strokes in currentColor, so they come out as line
art at whatever the text colour prints as — which is usually correct.
.icon-tile is in the
print-color-adjust: exact list in src/99-print.css,
alongside badges, avatars and chart fills — so a tile keeps its tint on paper rather
than printing as a glyph floating in white space. That is the difference between a
status tile that still reads as a status and one that does not.
Overriding it
/* One icon, no layer needed */
<svg class="icon" style="inline-size:2.5em;block-size:2.5em">
@layer app.components {
/* A lighter stroke throughout */
.icon { stroke-width: 1.5; }
/* A size Deck does not ship */
.icon-2xl { inline-size: 3em; block-size: 3em; }
}
Changing stroke-width affects only the stroked symbols. The sprite's
glyphs are filled outlines, so a heavier stroke does not make them heavier — that is
what the two weights are for.
When not to use it
-
Not as the only label. An icon-only button is fine with
aria-label, and a row of unlabelled glyphs is a guessing game for everyone. -
Not for a logo or a brand mark. Those are hand-authored symbols in
the sprite —
#deck-markand#deck-wordmark— and they are filled rather than stroked, so.icon's stroke settings do nothing useful for them. -
Not for an illustration. A 24-unit grid at
1.25emis not a picture. Use an<img>. -
Not for an emoji. Use
.emoji, which sizes and aligns a character rather than an SVG. -
Not with a fixed pixel size. The
emsizing is the component. Overriding it with pixels breaks the alignment with text and the behaviour under zoom.