Component
button
Demo
tokens.css,
base.css and button.css. No scripts.
Source
Copy both files, or run npx nojsui add button.
Browser support
Works across current and earlier versions of every major engine.
| Feature | Baseline | Chrome | Edge | Firefox | Safari | Chrome Android | Firefox Android | Safari iOS |
|---|
Usage
.sk-button goes on a native <button> or on an <a>. There is no wrapper
element and no <div role="button">.
<button type="button" class="sk-button">Send</button>
<a class="sk-button" data-variant="outline" href="/pricing/">See pricing</a>
data-variant | Fill |
|---|---|
| (omitted) | solid — the accent colour |
outline | No fill, a --sk-color-border-strong edge, text colour |
ghost | No fill, no edge, text colour |
gradient | A gradient built from the accent token |
data-size | Type and padding |
|---|---|
sm | --sk-text-xs |
| (omitted) | md — --sk-text-sm |
lg | --sk-text-md |
Every size keeps a --sk-size-tap-target minimum block size, so a small
button is still 44px tall (WCAG 2.5.5, Enhanced) — min-block-size alone
only guarantees that one dimension; a narrow sm button with little label
text can still be less than 44px wide.
The fill is a background, not a background-color
--sk-button-bg is consumed through the background shorthand, which accepts a
<color> or an <image> natively. So a gradient is a token value, not a
variant needing rules of its own:
.hero .sk-button {
--sk-button-bg: linear-gradient(135deg, #6366f1, #a855f7);
--sk-button-fg: white;
--sk-button-radius: var(--sk-radius-full);
}
data-variant="gradient" is exactly one declaration changing the default of
that same property. Nothing else in the component knows gradients exist.
The cost, stated plainly: contrast-color() cannot evaluate against an
image. With a colour fill the kit derives readable label text for you
(--sk-color-on-accent, see ADR 0022); with a gradient it cannot, so set
--sk-button-fg yourself. That is why the example above sets both.
The pending state reserves its own width
[aria-busy="true"] swaps the label for a pending one. Done naively the button
resizes mid-submission — “Send” is narrower than “Sending…” — which shifts
everything beside it at the worst possible moment.
.sk-button is an inline-grid and both labels occupy the same cell:
<button type="button" class="sk-button" aria-busy="true">
<span class="sk-button__label">Send</span>
<span class="sk-button__pending">
<span class="sk-button__spinner"></span>Sending…
</span>
</button>
The cell is already as wide as the wider of the two, so nothing moves. Only
visibility changes, which does not affect layout.
Why not min-inline-size? Because it makes you guess a number, and the
guess is wrong the moment the font swaps, the page is translated, or the
pending copy changes. The grid measures the real thing.
A button with no parts still works — <button class="sk-button">Send</button>
is one grid item — so you only reach for the two spans when you need the
pending state.
Disabled
:disabled and [aria-disabled="true"] are styled identically, because <a>
never matches :disabled.
Disabled swaps to a flat fill (--sk-color-surface) and muted text
(--sk-color-text-subtle) rather than dimming with opacity. A resting
element below full opacity is indistinguishable — to a reader and to the
kit’s own conformance suite — from content stranded by a feature the engine
lacks, so nothing in this component sits at partial opacity outside a
transient :hover/:active state. It is also the better answer here on its
own terms: --sk-button-bg may be an image, and a disabled gradient button
has no business still showing its gradient — a flat fill reads as disabled
regardless of what the enabled fill was.
A disabled link must not have an href. aria-disabled tells assistive
technology the control is inert; it does not stop a browser following a link.
Drop the href (or use a <button>) and the element stops being a link target
as well as looking like one.
Theming
Set any of these anywhere above the component — :root, a section wrapper, or
one instance. button.css only ever reads them, so the nearest declaration wins
(ADR 0011).
| Property | Default | Controls |
|---|---|---|
--sk-button-bg | --sk-color-accent | The fill. A <color> or an <image>. Ignored by outline and ghost, which draw none |
--sk-button-fg | --sk-color-on-accent | Label colour (--sk-color-text on outline and ghost) |
--sk-button-border-color | --sk-color-border-strong | The outline variant’s edge |
--sk-button-bg-hover | --sk-color-surface | Hover fill for outline and ghost |
--sk-button-radius | --sk-radius-md | Corner radius. --sk-radius-full for a pill |
--sk-button-press-scale | 0.98 | How far it presses on :active |
Keyboard contract
Nothing custom. It is a native <button> or <a>, so the browser’s own
behaviour applies: Tab to reach it, Enter to activate
(and Space on a <button>). The focus ring comes from base.css
and this component does not touch it.
Verified manually in VoiceOver and NVDA: not yet — do this before marking
the component done. What to check: that aria-busy="true" is announced as
busy, and that the hidden label is not read twice, since visibility: hidden
should remove it from the accessibility tree.
Accessibility notes
- The pressed
scaleand its transition are inside@media (prefers-reduced-motion: no-preference); underreducethe button does not move. - The spinner’s rotation is likewise gated. Under
reduceit renders as a static ring — present, so the busy state is still visible, but still. - Hover styling is inside
@media (hover: hover)so a touch device does not keep a stuck hover state after a tap. - Nothing here opts out of
forced-colors. In forced-colors mode the fill becomesHighlightand the labelHighlightTextthroughtokens.css.
Degradation
Baseline column from support.json — regenerate with pnpm support.
| Feature used | Baseline status | Behavior without it |
|---|---|---|
| CSS grid | widely | Nothing to degrade — grid is Baseline widely available and has been since 2017. It is what stacks the two labels in one cell. |
There is no @supports block in this component and no feature in it that an
engine in any support tier can miss. That is the point of a primitive: it is
the piece everything else assumes.