Button
.btn is Deck's action element: an inline-flex box with a
44px touch target, a focus ring, and
a colour set driven by four custom properties. It works on
<button>, <a>, and
<input type="submit"> without changing class.
When to use it
Use .btn when the element performs an action: submitting a form,
opening a dialog, running a command. Use a plain <a> when the
element navigates somewhere, even if you want it to look like a button — the
element decides what a screen reader announces and what the middle mouse button
does, and a class cannot override either.
A button that navigates is the single most common accessibility defect in a component library. If the thing has a destination, it is a link.
Variants
Eight looks, including the bare .btn. Each sets the four
--btn-* properties and inherits everything else, so a variant is a
colour change and never a layout change.
Default
Neutral action. The one to reach for when nothing else applies.
<button class="btn">Save changes</button>
<button class="btn" disabled>Disabled</button>
<a class="btn" href="#variants">As a link</a>
<button class="btn">Save changes</button>
<button class="btn" disabled>Disabled</button>
<a class="btn" href="#variants">As a link</a>
Primary
The single most important action in a view. One per screen.
<button class="btn btn-primary">Save changes</button>
<button class="btn btn-primary" disabled>Disabled</button>
<a class="btn btn-primary" href="#variants">As a link</a>
<button class="btn btn-primary">Save changes</button>
<button class="btn btn-primary" disabled>Disabled</button>
<a class="btn btn-primary" href="#variants">As a link</a>
Accent
A secondary emphasis colour, for upsells and highlights.
<button class="btn btn-accent">Save changes</button>
<button class="btn btn-accent" disabled>Disabled</button>
<a class="btn btn-accent" href="#variants">As a link</a>
<button class="btn btn-accent">Save changes</button>
<button class="btn btn-accent" disabled>Disabled</button>
<a class="btn btn-accent" href="#variants">As a link</a>
Danger
Destructive and irreversible. Pair it with a confirmation.
<button class="btn btn-danger">Save changes</button>
<button class="btn btn-danger" disabled>Disabled</button>
<a class="btn btn-danger" href="#variants">As a link</a>
<button class="btn btn-danger">Save changes</button>
<button class="btn btn-danger" disabled>Disabled</button>
<a class="btn btn-danger" href="#variants">As a link</a>
Soft
Tinted, low weight. Good for a repeated action inside a list.
<button class="btn btn-soft">Save changes</button>
<button class="btn btn-soft" disabled>Disabled</button>
<a class="btn btn-soft" href="#variants">As a link</a>
<button class="btn btn-soft">Save changes</button>
<button class="btn btn-soft" disabled>Disabled</button>
<a class="btn btn-soft" href="#variants">As a link</a>
Ghost
No chrome until hover. For toolbars and dense layouts.
<button class="btn btn-ghost">Save changes</button>
<button class="btn btn-ghost" disabled>Disabled</button>
<a class="btn btn-ghost" href="#variants">As a link</a>
<button class="btn btn-ghost">Save changes</button>
<button class="btn btn-ghost" disabled>Disabled</button>
<a class="btn btn-ghost" href="#variants">As a link</a>
Outline
Brand-coloured border, transparent fill.
<button class="btn btn-outline">Save changes</button>
<button class="btn btn-outline" disabled>Disabled</button>
<a class="btn btn-outline" href="#variants">As a link</a>
<button class="btn btn-outline">Save changes</button>
<button class="btn btn-outline" disabled>Disabled</button>
<a class="btn btn-outline" href="#variants">As a link</a>
Link
Looks like a link, behaves like a button. See the caveat below.
<button class="btn btn-link">Save changes</button>
<button class="btn btn-link" disabled>Disabled</button>
<a class="btn btn-link" href="#variants">As a link</a>
<button class="btn btn-link">Save changes</button>
<button class="btn btn-link" disabled>Disabled</button>
<a class="btn btn-link" href="#variants">As a link</a>
Sizes
Three heights, set from --control-h-sm, --control-h, and
--control-h-lg. The small size is
34px tall but still clears the
44px touch target, because the target is a pseudo-element rather than the
box itself.
<button class="btn btn-sm">Small</button>
<button class="btn">Default</button>
<button class="btn btn-lg">Large</button>
<button class="btn btn-sm">Small</button>
<button class="btn">Default</button>
<button class="btn btn-lg">Large</button>
Every variant at every size
Eight variants across three sizes, so the whole matrix is visible at once.
<div class="cluster cluster-tight">
<button class="btn btn-sm">Default</button>
<button class="btn">Default</button>
<button class="btn btn-lg">Default</button>
</div>
<div class="cluster cluster-tight">
<button class="btn btn-primary btn-sm">Primary</button>
<button class="btn btn-primary">Primary</button>
<button class="btn btn-primary btn-lg">Primary</button>
</div>
<div class="cluster cluster-tight">
<button class="btn btn-accent btn-sm">Accent</button>
<button class="btn btn-accent">Accent</button>
<button class="btn btn-accent btn-lg">Accent</button>
</div>
<div class="cluster cluster-tight">
<button class="btn btn-danger btn-sm">Danger</button>
<button class="btn btn-danger">Danger</button>
<button class="btn btn-danger btn-lg">Danger</button>
</div>
<div class="cluster cluster-tight">
<button class="btn btn-soft btn-sm">Soft</button>
<button class="btn btn-soft">Soft</button>
<button class="btn btn-soft btn-lg">Soft</button>
</div>
<div class="cluster cluster-tight">
<button class="btn btn-ghost btn-sm">Ghost</button>
<button class="btn btn-ghost">Ghost</button>
<button class="btn btn-ghost btn-lg">Ghost</button>
</div>
<div class="cluster cluster-tight">
<button class="btn btn-outline btn-sm">Outline</button>
<button class="btn btn-outline">Outline</button>
<button class="btn btn-outline btn-lg">Outline</button>
</div>
<div class="cluster cluster-tight">
<button class="btn btn-link btn-sm">Link</button>
<button class="btn btn-link">Link</button>
<button class="btn btn-link btn-lg">Link</button>
</div>
<div class="cluster cluster-tight">
<button class="btn btn-sm">Default</button>
<button class="btn">Default</button>
<button class="btn btn-lg">Default</button>
</div>
<div class="cluster cluster-tight">
<button class="btn btn-primary btn-sm">Primary</button>
<button class="btn btn-primary">Primary</button>
<button class="btn btn-primary btn-lg">Primary</button>
</div>
<div class="cluster cluster-tight">
<button class="btn btn-accent btn-sm">Accent</button>
<button class="btn btn-accent">Accent</button>
<button class="btn btn-accent btn-lg">Accent</button>
</div>
<div class="cluster cluster-tight">
<button class="btn btn-danger btn-sm">Danger</button>
<button class="btn btn-danger">Danger</button>
<button class="btn btn-danger btn-lg">Danger</button>
</div>
<div class="cluster cluster-tight">
<button class="btn btn-soft btn-sm">Soft</button>
<button class="btn btn-soft">Soft</button>
<button class="btn btn-soft btn-lg">Soft</button>
</div>
<div class="cluster cluster-tight">
<button class="btn btn-ghost btn-sm">Ghost</button>
<button class="btn btn-ghost">Ghost</button>
<button class="btn btn-ghost btn-lg">Ghost</button>
</div>
<div class="cluster cluster-tight">
<button class="btn btn-outline btn-sm">Outline</button>
<button class="btn btn-outline">Outline</button>
<button class="btn btn-outline btn-lg">Outline</button>
</div>
<div class="cluster cluster-tight">
<button class="btn btn-link btn-sm">Link</button>
<button class="btn btn-link">Link</button>
<button class="btn btn-link btn-lg">Link</button>
</div>
Icon-only, round, and block
.btn-icon makes the button square by dropping the inline padding, so it
needs an aria-label — there is no text for a screen reader to announce.
.btn-round only changes the radius. .btn-block fills its
container.
<button class="btn btn-icon" aria-label="Settings">
<svg class="icon"><use href="../../assets/deck/deck-icons.svg#settings"></use></svg>
</button>
<button class="btn btn-icon btn-round btn-primary" aria-label="Add">
<svg class="icon"><use href="../../assets/deck/deck-icons.svg#plus"></use></svg>
</button>
<button class="btn btn-icon btn-sm btn-ghost" aria-label="More">
<svg class="icon icon-sm"><use href="../../assets/deck/deck-icons.svg#more-horizontal-sm"></use></svg>
</button>
<button class="btn btn-icon" aria-label="Settings">
<svg class="icon"><use href="../../assets/deck/deck-icons.svg#settings"></use></svg>
</button>
<button class="btn btn-icon btn-round btn-primary" aria-label="Add">
<svg class="icon"><use href="../../assets/deck/deck-icons.svg#plus"></use></svg>
</button>
<button class="btn btn-icon btn-sm btn-ghost" aria-label="More">
<svg class="icon icon-sm"><use href="../../assets/deck/deck-icons.svg#more-horizontal-sm"></use></svg>
</button>
<button class="btn btn-primary btn-block">Block, fills its container</button>
<button class="btn btn-primary btn-block">Block, fills its container</button>
Pressable
.btn-3d lives in src/21-space3d.css rather than with the
other button classes, because it is an effect rather than a variant. It replaces the
border with a stacked shadow and translates the button down by
--lift on :active, so the press is physical. It is purely
decorative, so it is removed entirely under reduced motion rather than shortened.
<button class="btn btn-primary btn-3d">Press me</button>
<button class="btn btn-primary btn-3d">Press me</button>
Groups
.btn-group joins adjacent buttons into a segmented control: it squares
the inner corners, collapses the shared border with a negative margin, and raises the
hovered button so its outline is not clipped. It uses logical radius properties, so
the rounded ends swap under dir="rtl".
<div class="btn-group">
<button class="btn btn-sm">Day</button>
<button class="btn btn-sm">Week</button>
<button class="btn btn-sm">Month</button>
</div>
<div class="btn-group">
<button class="btn btn-sm">Day</button>
<button class="btn btn-sm">Week</button>
<button class="btn btn-sm">Month</button>
</div>
States
.is-loading hides the label and draws a spinner in its place, and sets
pointer-events: none so the click cannot repeat. Disabled is a real
attribute; .is-disabled exists for elements that cannot take one, such
as an anchor, and must be paired with aria-disabled.
<button class="btn btn-primary is-loading">Submitting</button>
<button class="btn" disabled>Disabled</button>
<a class="btn is-disabled" aria-disabled="true" href="#states">Unavailable link</a>
<button class="btn btn-primary is-loading">Submitting</button>
<button class="btn" disabled>Disabled</button>
<a class="btn is-disabled" aria-disabled="true" href="#states">Unavailable link</a>
The spinner keeps turning under prefers-reduced-motion: reduce, slowed
to a 2.4 second rotation rather than stopped. A frozen spinner reads as a hung page,
and progress is information rather than decoration.
Floating action button
.fab is a fixed, circular action parked above the tab bar and clear of
the home indicator. It positions itself with env(safe-area-inset-bottom)
and lifts further when the page has a .tabbar, so it does not need a
media query or a magic number.
It is fixed to the viewport, so it is described rather than rendered here — see it in the bottom corner of the component demo.
<button class="fab" aria-label="New item">
<svg class="icon icon-lg"><use href="/assets/deck/deck-icons.svg#plus"></use></svg>
</button>
Classes
Generated from src/05-buttons.css by
tools/docs/extract.mjs. If a class is added or removed in the source and
this table is not updated, tools/docs/verify.mjs fails the build.
| Class | Layer | What it does | Source |
|---|---|---|---|
.btn |
deck.components |
Every button clears the 44px touch target even when it looks smaller | 05-buttons.css:7 |
.btn-primary |
deck.components |
The one important action in a view. Solid brand fill; use exactly one per screen so it keeps meaning something. | 05-buttons.css:72 |
.btn-accent |
deck.components |
A secondary emphasis colour, for upsells and highlights. | 05-buttons.css:81 |
.btn-danger |
deck.components |
Destructive and irreversible. Pair it with a confirmation. | 05-buttons.css:90 |
.btn-soft |
deck.components |
Tinted, low weight. Good for an action repeated down a list. | 05-buttons.css:99 |
.btn-ghost |
deck.components |
No border or background until hover. For toolbars and dense rows where seven bordered buttons would be noise. | 05-buttons.css:109 |
.btn-outline |
deck.components |
Brand-coloured border, transparent fill. | 05-buttons.css:117 |
.btn-link |
deck.components |
Looks like a link, behaves like a button. Only for a real <button> that has to sit in a row of links — on an anchor it adds a touch target, a ring offset and nowrap to something that wanted none of them. | 05-buttons.css:128 |
.btn-sm |
deck.components |
The 34px size. Still clears the 44px touch target, because the target is a pseudo-element rather than the box. | 05-buttons.css:141 |
.btn-lg |
deck.components |
The large size, from --control-h-lg. | 05-buttons.css:149 |
.btn-block |
deck.components |
Full width. For a phone, or the primary action at the end of a form. | 05-buttons.css:156 |
.btn-icon |
deck.components |
Square, icon-only button. It has no text, so it needs an aria-label. | 05-buttons.css:159 |
.btn-round |
deck.components |
A fully rounded button, usually paired with .btn-icon. | 05-buttons.css:163 |
.btn-group |
deck.components |
Buttons joined into one control, with the inner radii and the doubled border between them removed. Logical radii, so it mirrors under RTL. | 05-buttons.css:168 |
.btn-3d |
deck.effects |
A button with real thickness. The face moves down into the shadow instead of just changing color. | 21-space3d.css:196 |
.fab |
deck.components |
Floating action button, parked above the tab bar and the notch | 05-buttons.css:191 |
.is-loading |
deck.reset |
No declarations. | 02-reset.css:92 |
.is-disabled |
deck.components |
No declarations. | 05-buttons.css:63 |
Tokens
.btn sets four local properties from global tokens, then every variant
overrides those four. Restyling all buttons is a matter of changing
--btn-bg and friends rather than re-declaring the component.
| Token | Live | Declared value |
|---|---|---|
--btn-bg |
not in the inventory | inherited |
--btn-text |
not in the inventory | inherited |
--btn-border |
not in the inventory | inherited |
--btn-shadow |
not in the inventory | inherited |
Those four read from these globals:
| Token | Live | Declared value |
|---|---|---|
--surface |
light-dark(var(--ink-0), var(--ink-950)) |
|
--text |
light-dark(var(--ink-900), var(--ink-100)) |
|
--line-strong |
light-dark(var(--ink-300), oklch(36% .018 var(--hue-neutral))) |
|
--shadow-1 |
0 1px 2px oklch(20% .03 var(--shadow-color) / .06), 0 1px 1px oklch(20% .03 var(--shadow-color) / .04) |
|
--control-h |
42px |
|
--control-h-sm |
34px |
|
--control-h-lg |
50px |
|
--tap |
44px |
|
--r-sm |
7px |
|
--focus |
light-dark(var(--brand-500), var(--brand-300)) |
Accessibility
-
Touch target.
.btnis 42px tall, below the 44px minimum. A::beforepseudo-element centred on the button carriesmin-inline-sizeandmin-block-sizeof--tap, so the hit area clears the minimum without the button looking oversized..btn-smgets the same treatment. -
Focus. A two-pixel
--focusoutline with a 2px offset, on:focus-visibleonly, so a pointer click does not leave a ring but keyboard navigation always does. -
Icon-only buttons need a label.
.btn-iconremoves the text; addaria-label. The icon itself is decorative. -
Disabled anchors.
.is-disabledstyles the element but cannot stop a click the waydisableddoes. Addaria-disabled="true", and remove thehrefif it must not be followed. -
Loading.
.is-loadinghides the label visually. Addaria-busy="true"so the state is announced, and keep the accessible name on the button.
Right to left
.btn is written in logical properties, so dir="rtl" mirrors
it with no extra stylesheet: padding-inline, the group's
border-start-start-radius, and the negative
margin-inline-start all follow the writing direction. An icon inside a
button mirrors only if it points — Deck mirrors arrows, chevrons, and
#send, and leaves checkmarks and clocks alone.
<div dir="rtl" class="cluster">
<button class="btn btn-primary">حفظ التغييرات</button>
<div class="btn-group">
<button class="btn btn-sm">يوم</button>
<button class="btn btn-sm">أسبوع</button>
</div>
</div>
<div dir="rtl" class="cluster">
<button class="btn btn-primary">حفظ التغييرات</button>
<div class="btn-group">
<button class="btn btn-sm">يوم</button>
<button class="btn btn-sm">أسبوع</button>
</div>
</div>
Reduced motion
.btn transitions background, border, colour, shadow, and a one-pixel
press. Under prefers-reduced-motion: reduce the reset collapses every
transition to .01ms, so the press becomes instant rather than animated.
The loading spinner is deliberately exempt and slows to 2.4 seconds instead of
stopping.
.btn-3d, from src/21-space3d.css, is the one button effect
that is purely decorative — its shadow-and-translate press is removed entirely rather
than slowed.
Overriding it
Deck ships inside cascade layers and reserves four empty app.* layers
for application CSS. A rule in app.components beats every Deck rule
regardless of specificity, so overriding a button needs a single class selector and
no !important.
@layer app.components {
.btn {
--btn-bg: var(--brand-50);
border-radius: var(--r-full);
}
}
Prefer changing the four --btn-* properties over re-declaring
background and color: the variants set those properties, so
a token override survives .btn-primary being added later while a flat
background override does not.
When not to use it
-
.btn-linkversus a plain anchor. Reach for.btn-linkonly when the element is genuinely a<button>that has to sit in a row of links — cancelling a dialog, toggling a disclosure. If it navigates, use a bare<a>and let it look like a link..btn-linkon an anchor adds a 44px target, a focus ring offset, andwhite-space: nowrapto something that wanted none of them, and it will not wrap mid-sentence. -
Not for navigation bars. Use
.nav-linkor.sidebar-link. They are sized for a row of destinations and carry the current-page state; a row of.btnelements reads as a row of commands. -
Not for a toggle with two states. Use
.switchfor on and off, or.segmentedfor one-of-three. A button that changes its own label is harder to announce and harder to script. -
Not for a whole clickable card. Use
.card-linkwith a.stretchanchor, which keeps the text selectable and gives the card a single accessible name.