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.

As a link
<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.

As a link
<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.

As a link
<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.

As a link
<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.

As a link
<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.

As a link
<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.

As a link
<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>

Looks like a link, behaves like a button. See the caveat below.

As a link
<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.

Unavailable link
<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.

Classes, generated from the stylesheet source
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.

Design tokens this component reads
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:

Design tokens this component reads
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. .btn is 42px tall, below the 44px minimum. A ::before pseudo-element centred on the button carries min-inline-size and min-block-size of --tap, so the hit area clears the minimum without the button looking oversized. .btn-sm gets the same treatment.
  • Focus. A two-pixel --focus outline with a 2px offset, on :focus-visible only, so a pointer click does not leave a ring but keyboard navigation always does.
  • Icon-only buttons need a label. .btn-icon removes the text; add aria-label. The icon itself is decorative.
  • Disabled anchors. .is-disabled styles the element but cannot stop a click the way disabled does. Add aria-disabled="true", and remove the href if it must not be followed.
  • Loading. .is-loading hides the label visually. Add aria-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.

The same markup with dir="rtl" on the wrapper
<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-link versus a plain anchor. Reach for .btn-link only 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-link on an anchor adds a 44px target, a focus ring offset, and white-space: nowrap to something that wanted none of them, and it will not wrap mid-sentence.
  • Not for navigation bars. Use .nav-link or .sidebar-link. They are sized for a row of destinations and carry the current-page state; a row of .btn elements reads as a row of commands.
  • Not for a toggle with two states. Use .switch for on and off, or .segmented for 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-link with a .stretch anchor, which keeps the text selectable and gives the card a single accessible name.