Button
Button — the reference implementation.
Examples
<button class="btn btn-primary" type="button">Save changes</button>
<button class="btn btn-secondary" type="button">Preview</button>
<button class="btn btn-accent" type="button">Publish</button>
<button class="btn btn-outline" type="button">Cancel</button>
<button class="btn btn-quiet" type="button">Dismiss</button>
<button class="btn btn-danger" type="button">Delete</button><button class="btn btn-primary" type="button">
<svg class="icon" aria-hidden="true"><use href="#icon-plus"></use></svg>
New invoice
</button>
<button class="btn btn-outline" type="button">
Export
<svg class="icon" aria-hidden="true"><use href="#icon-external-link"></use></svg>
</button>
<button class="btn btn-danger btn-sm" type="button">
<svg class="icon" aria-hidden="true"><use href="#icon-trash"></use></svg>
Delete
</button>Put the <svg> inside the button and nothing else is needed: .btn is a flex row with a gap, so a mark before or after the label lines up on its own and the icon scales with the button’s size.
The glyph is always aria-hidden="true" — the words beside it are the name, and an icon that also announced itself would say the same thing twice. A mark before the label describes what the action creates; a mark after it says where the action takes you, which is why the external-link arrow is on the trailing side. Where there is no label at all, use .btn-icon and put the name on the button, as in the next example.
<button class="btn btn-primary btn-sm" type="button">Small</button>
<button class="btn btn-primary" type="button">Default</button>
<button class="btn btn-primary btn-lg" type="button">Large</button>
<button class="btn btn-outline btn-round" type="button">Round</button>
<button class="btn btn-outline btn-icon" type="button" aria-label="Search">
<svg class="icon" aria-hidden="true"><use href="#icon-search"></use></svg>
</button>The icon-only button carries its name on the button, never on the <svg>.
<button class="btn btn-primary" type="button" disabled>Disabled</button>
<button class="btn btn-outline" type="button" aria-pressed="true">Pressed</button>
<button class="btn btn-primary" type="button" data-loading aria-busy="true" disabled>
Saving
</button>
<div class="btn-group" role="group" aria-label="Text alignment">
<button class="btn" type="button" aria-pressed="true">Left</button>
<button class="btn" type="button" aria-pressed="false">Centre</button>
<button class="btn" type="button" aria-pressed="false">Right</button>
</div>:disabled and [aria-pressed] are read straight from the element. data-loading is the exception — the platform has no notion of it.
<div class="btn-group btn-split">
<button class="btn btn-primary" type="button">Save</button>
<button class="btn btn-primary btn-split-menu" type="button"
command="toggle-popover" commandfor="save-menu">
<span class="sr-only">More save options</span>
</button>
</div>
<div class="menu popover popover-plain" id="save-menu" popover role="menu"
aria-label="More save options" data-ui="menu">
<button class="menu-item" type="button" role="menuitem">Save a copy</button>
<button class="menu-item" type="button" role="menuitem">Save as template</button>
</div>data-ui="menu" goes on the panel and nowhere else — it is what writes aria-expanded back onto the button, since an invoker command supplies none.
Putting a second data-ui="popover" on the button is the mistake this example shipped with, and the module says so in the console: the attribute names an element that has an open state, and a button does not have one. This was a component of its own called split-button. It is not one: it is a .btn-group holding two .btns, and every variant, size and state already applies to both halves — so the classes moved here and took the button prefix. The chevron is drawn in CSS rather than asked of you, because an <svg> here is a decorative element somebody has to remember to hide and forgetting is how "chevron down" becomes a button's name. The two halves are two Tab stops, deliberately: they are two buttons.
Tokens 17
Level 2, declared on .btn itself. Set any of them on that selector to restyle this component without touching the skin.
| Token | Default |
|---|---|
| --btn-split-chevron-size | 0.375rem |
| --btn-split-chevron-thickness | var(--border-width-strong) |
| --btn-split-divider-opacity | 0.32 |
| --btn-split-menu-padding-inline | var(--space-2) |
| --btn-active-scale | 0.97 |
| --btn-bg | transparent |
| --btn-border-color | var(--color-border-strong) |
| --btn-border-width | var(--border-width) |
| --btn-color | var(--color-on-surface) |
| --btn-font-family | var(--font-secondary) |
| --btn-font-size | var(--font-size-sm) |
| --btn-font-weight | var(--font-weight-medium) |
| --btn-gap | var(--space-2) |
| --btn-min-size | var(--target-size) |
| --btn-padding-block | var(--space-2) |
| --btn-padding-inline | var(--space-4) |
| --btn-radius | var(--radius-field) |
Variants and states
Variants
.btn-accent.btn-primary.btn-secondary.btn-outline.btn-quiet.btn-danger.btn-lg.btn-round.btn-sm.btn-split
Inside it
.btn-group.btn-split-menu.btn-icon
State it reads
Read from the platform, never mirrored into a class that could disagree with it.
:active:disabled:focus:hoverdata-loading
Before you ship it
What you have to do 1 requirements
- An icon-only button has no accessible name. Give it one on the button —
aria-label, or<span class="sr-only">inside — never on the<svg>, which isaria-hidden. Nothing in CSS can supply this and nothing can detect its absence at runtime, so it is on you.
src/css/components/button.css · npx mostlycss add button