Skip to content

Component

button

Demo

Live demo, isolated in an iframe with only tokens.css, base.css and button.css. No scripts.

Source

Copy both files, or run npx nojsui add button.

Source for button
<!-- .sk-button goes on a native <button> or an <a>. There is no wrapper and no
     <div role="button">.

     The three rows below are the three things the API has to get right: a
     gradient pill built only from --sk-button-bg and --sk-button-radius, a
     full-width submit in its pending state, and a pair of page actions.

     .sk-button__row is demo layout and nothing else. -->
<div class="sk-button__row">
  <a
    class="sk-button"
    data-variant="gradient"
    data-size="lg"
    href="https://nojsui.com/components/"
    style="--sk-button-radius: var(--sk-radius-full)"
  >
    Start a project
  </a>
  <button type="button" class="sk-button">Solid</button>
  <button type="button" class="sk-button" data-variant="outline">Outline</button>
  <button type="button" class="sk-button" data-variant="ghost">Ghost</button>
  <button type="button" class="sk-button" data-size="sm">Small</button>
  <button type="button" class="sk-button" disabled>Disabled</button>
</div>

<!-- The pending state. Both labels live in one grid cell, so the button is
     already as wide as "Sending…" and does not resize when the state flips. -->
<div class="sk-button__row" data-row="block">
  <button type="button" class="sk-button" aria-busy="true">
    <span class="sk-button__label">Send message</span>
    <span class="sk-button__pending">
      <span class="sk-button__spinner"></span>
      Sending…
    </span>
  </button>
</div>
/* .sk-button — the kit's one button, on a native <button> or an <a>.

   TWO DECISIONS SHAPE THIS FILE.

   1. The fill goes through the `background` SHORTHAND, not background-color.
      The shorthand accepts a <color> or an <image>, so --sk-button-bg takes
      either and data-variant="gradient" is one declaration changing that
      property's default rather than a ruleset of its own. The cost is real and
      documented: contrast-color() cannot evaluate against an image, so a
      gradient fill means setting --sk-button-fg too.

   2. The pending state is a GRID STACK. Both labels sit in one cell and only
      `visibility` changes, so the button is already as wide as the wider label
      and does not resize when "Send" becomes "Sending…". min-inline-size would
      make the consumer guess a number that breaks under a font swap or a
      translation; the grid measures the real thing.

   NO FOCUS RING HERE. base.css draws one ring for the whole kit and components
   never restyle it (CLAUDE.md rule 9).

   Specs:
   - background shorthand ... https://drafts.csswg.org/css-backgrounds-3/#the-background
   - grid-area .............. https://drafts.csswg.org/css-grid-2/#propdef-grid-area
   - visibility ............. https://drafts.csswg.org/css-display-3/#propdef-visibility
   - aria-busy .............. https://w3c.github.io/aria/#aria-busy */

/* Public theme knobs (ADR 0011). Read, never declared, so the nearest
   declaration above the component wins. */
.sk-button {
  --_bg: var(--sk-button-bg, var(--sk-color-accent));
  --_fg: var(--sk-button-fg, var(--sk-color-on-accent));
  --_border-color: var(--sk-button-border-color, var(--sk-color-border-strong));
  --_bg-hover: var(--sk-button-bg-hover, var(--sk-color-surface));
  --_radius: var(--sk-button-radius, var(--sk-radius-md));
  --_press-scale: var(--sk-button-press-scale, 0.98);

  /* Size, as private locals the data-size rules below re-point. */
  --_padding-block: var(--sk-space-xs);
  --_padding-inline: var(--sk-space-md);
  --_font-size: var(--sk-text-sm);

  /* inline-grid, not inline-flex: the pending state needs two children in ONE
     cell, which only grid gives. A button with no children still works — it is
     a grid with one item. */
  display: inline-grid;
  grid-template-areas: "label";
  place-items: center;
  min-block-size: var(--sk-size-tap-target);
  padding-block: var(--_padding-block);
  padding-inline: var(--_padding-inline);

  /* transparent as a literal, not as a var() fallback: the theme contract
     requires colour fallbacks to reach a global token, and rightly — but here
     no border is DRAWN unless the outline variant asks for one. */
  border: var(--sk-border-width) solid transparent;
  border-radius: var(--_radius);
  background: var(--_bg);
  color: var(--_fg);
  font-size: var(--_font-size);
  font-weight: var(--sk-weight-medium);
  line-height: var(--sk-leading-tight);
  text-align: center;

  /* For the <a> case. A button never has a default underline; a link does. */
  text-decoration: none;
}

/* --- variants ----------------------------------------------------------- */

/* `background: none` rather than routing a transparent value through --_bg,
   for the same reason as the border above. Documented consequence: these two
   variants ignore --sk-button-bg. */
.sk-button[data-variant="outline"],
.sk-button[data-variant="ghost"] {
  --_fg: var(--sk-button-fg, var(--sk-color-text));

  background: none;
}

.sk-button[data-variant="outline"] {
  border-color: var(--_border-color);
}

/* The whole gradient variant. One property, because of decision 1 above. */
.sk-button[data-variant="gradient"] {
  --_bg: var(
    --sk-button-bg,
    linear-gradient(
      135deg,
      var(--sk-color-accent),
      color-mix(in oklab, var(--sk-color-accent) 70%, var(--sk-color-text))
    )
  );
}

/* --- sizes -------------------------------------------------------------- */

.sk-button[data-size="sm"] {
  --_padding-block: var(--sk-space-2xs);
  --_padding-inline: var(--sk-space-sm);
  --_font-size: var(--sk-text-xs);
}

.sk-button[data-size="lg"] {
  --_padding-block: var(--sk-space-sm);
  --_padding-inline: var(--sk-space-lg);
  --_font-size: var(--sk-text-md);
}

/* --- pending ------------------------------------------------------------
   Both labels in the same grid cell. Only `visibility` changes, and
   visibility does not affect layout — so the cell keeps the width of the
   wider label and nothing moves when the state flips. `visibility: hidden`
   also removes the inactive label from the accessibility tree, so it is not
   announced twice. */

.sk-button__label,
.sk-button__pending {
  grid-area: label;
  display: inline-flex;
  gap: var(--sk-space-2xs);
  align-items: center;
  visibility: hidden;
}

.sk-button__label {
  visibility: visible;
}

.sk-button[aria-busy="true"] .sk-button__label {
  visibility: hidden;
}

.sk-button[aria-busy="true"] .sk-button__pending {
  visibility: visible;
}

/* 1em so it tracks the label's size across all three data-size values.
   border-inline-end-color leaves the gap that makes the ring read as a
   spinner; it is logical, so it flips in RTL and nothing looks wrong. */
.sk-button__spinner {
  inline-size: 1em;
  block-size: 1em;
  border: var(--sk-border-width) solid currentcolor;
  border-inline-end-color: transparent;
  border-radius: var(--sk-radius-full);
}

/* --- states ------------------------------------------------------------- */

/* <a> never matches :disabled, so both spellings carry the same styling.
   base.css already sets cursor: not-allowed on each.

   No opacity here, deliberately. The shared conformance check flags any
   visible element sitting below opacity 1 at rest, because that is how a
   reveal hidden by a missing feature looks — and it cannot tell the two
   apart. A flat treatment is also the better answer for this component:
   --sk-button-bg may be an image, and a disabled gradient button has no
   business still showing its gradient. */
.sk-button:disabled,
.sk-button[aria-disabled="true"] {
  background: var(--sk-color-surface);
  color: var(--sk-color-text-subtle);
  border-color: transparent;
}

/* Inside @media (hover: hover) so a tap does not leave a stuck hover state. */
@media (hover: hover) {
  .sk-button:hover:not(:disabled, [aria-disabled="true"]) {
    /* Dimming rather than lightening the fill: --sk-button-bg may be an image,
       and there is no token arithmetic that works on both a colour and a
       gradient. */
    opacity: 0.92;
  }

  .sk-button[data-variant="outline"]:hover:not(:disabled),
  .sk-button[data-variant="ghost"]:hover:not(:disabled) {
    opacity: 1;
    background: var(--_bg-hover);
  }
}

@media (prefers-reduced-motion: no-preference) {
  .sk-button {
    transition: scale var(--sk-motion-instant) var(--sk-ease-out);
  }

  .sk-button:active:not(:disabled, [aria-disabled="true"]) {
    scale: var(--_press-scale);
  }

  .sk-button__spinner {
    animation: sk-button-spin 700ms linear infinite;
  }

  @keyframes sk-button-spin {
    to {
      rotate: 1turn;
    }
  }
}

/* ---------------------------------------------------------------------------
   Presentation, not behaviour

   The row below is the demo's layout. Delete it when copying .sk-button into
   your own markup.
--------------------------------------------------------------------------- */

.sk-button__row {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sk-space-sm);
  align-items: center;
  margin-block-end: var(--sk-space-lg);
}

.sk-button__row[data-row="block"] {
  display: grid;
  max-inline-size: 22rem;
}

Browser support

Baseline widely available

Works across current and earlier versions of every major engine.

Per-feature support, generated from web-features 3.35.0
FeatureBaselineChromeEdgeFirefoxSafariChrome AndroidFirefox AndroidSafari 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-variantFill
(omitted)solid — the accent colour
outlineNo fill, a --sk-color-border-strong edge, text colour
ghostNo fill, no edge, text colour
gradientA gradient built from the accent token
data-sizeType 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).

PropertyDefaultControls
--sk-button-bg--sk-color-accentThe fill. A <color> or an <image>. Ignored by outline and ghost, which draw none
--sk-button-fg--sk-color-on-accentLabel colour (--sk-color-text on outline and ghost)
--sk-button-border-color--sk-color-border-strongThe outline variant’s edge
--sk-button-bg-hover--sk-color-surfaceHover fill for outline and ghost
--sk-button-radius--sk-radius-mdCorner radius. --sk-radius-full for a pill
--sk-button-press-scale0.98How 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 scale and its transition are inside @media (prefers-reduced-motion: no-preference); under reduce the button does not move.
  • The spinner’s rotation is likewise gated. Under reduce it 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 becomes Highlight and the label HighlightText through tokens.css.

Degradation

Baseline column from support.json — regenerate with pnpm support.

Feature usedBaseline statusBehavior without it
CSS gridwidelyNothing 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.