Skip to content

Component

checkbox

Demo

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

Source

Copy both files, or run npx nojsui add checkbox.

Source for checkbox
<!-- .sk-checkbox — checkbox, switch and radio. One component, because all three
     are the same control wearing different clothes, and rule 7 forbids
     cross-component imports that would let them share scaffolding.

     Every one of them is a real <input>. The label wraps the control, so the
     text is the control's accessible name and clicking it toggles — no `for`
     needed, and no way for the two to drift apart.

     The switch is <input type="checkbox" role="switch">, NOT the native
     `switch` attribute. That attribute is Safari-only (17.4+, no Chrome, no
     Firefox), so using it behind @supports would mean maintaining and
     snapshotting two visually different switches for one engine's benefit.
     See the README. -->
<fieldset class="sk-checkbox__set">
  <legend class="sk-checkbox__legend">Notifications</legend>

  <label class="sk-checkbox">
    <input class="sk-checkbox__input" type="checkbox" name="email" checked />
    <span class="sk-checkbox__text">Email me about replies</span>
  </label>

  <label class="sk-checkbox">
    <input class="sk-checkbox__input" type="checkbox" name="digest" />
    <span class="sk-checkbox__text">Weekly digest</span>
  </label>

  <label class="sk-checkbox" data-variant="switch">
    <input class="sk-checkbox__input" type="checkbox" role="switch" name="sounds" />
    <span class="sk-checkbox__text">Play a sound</span>
  </label>
</fieldset>

<!-- A radio group with nothing checked. Every radio in it matches
     :indeterminate — the one way that pseudo-class is reachable from markup —
     but nothing here styles it: WebKit never recomputes styles that depend on
     an :indeterminate change unless they are on the input itself, and the
     properties it does honour there mean "focus" or "disabled". See the README
     and ADR 0018. -->
<fieldset class="sk-checkbox__set">
  <legend class="sk-checkbox__legend">Plan</legend>

  <label class="sk-checkbox">
    <input class="sk-checkbox__input" type="radio" name="plan" value="free" />
    <span class="sk-checkbox__text">Free</span>
  </label>

  <label class="sk-checkbox">
    <input class="sk-checkbox__input" type="radio" name="plan" value="pro" />
    <span class="sk-checkbox__text">Pro</span>
  </label>

  <label class="sk-checkbox">
    <input class="sk-checkbox__input" type="radio" name="plan" value="team" />
    <span class="sk-checkbox__text">Team</span>
  </label>
</fieldset>
/* .sk-checkbox — checkbox, switch and radio.

   THE PRINCIPLE, shared with range and recorded in ADR 0017: tint the
   platform's control, and only draw one when the platform has none.

   So the checkbox and the radio are native, coloured with accent-color. They
   keep their own focus affordance, their own forced-colors rendering, their
   own indeterminate dash and their own platform feel, and this file stays
   small enough to be obviously correct. The switch is drawn here with
   appearance: none, because there is no cross-engine native switch to tint —
   Safari's `switch` attribute is Safari-only.

   The knob is a background-image on the input itself, not a pseudo-element:
   generated content on a form control is unreliable across engines, and this
   way there is exactly one box and nothing to keep in sync with it.

   Specs:
   - accent-color .......... https://drafts.csswg.org/css-ui-4/#widget-accent
   - :indeterminate ........ https://html.spec.whatwg.org/multipage/semantics-other.html#selector-indeterminate
   - :dir() ................ https://drafts.csswg.org/selectors-4/#the-dir-pseudo */

/* Public theme knobs (ADR 0011). Read, never declared. */
.sk-checkbox {
  --_accent: var(--sk-checkbox-accent, var(--sk-color-accent));
  --_label-fg: var(--sk-checkbox-label-fg, var(--sk-color-text));
  --_gap: var(--sk-checkbox-gap, var(--sk-space-xs));
  --_track-bg: var(--sk-checkbox-switch-track-bg, var(--sk-color-border-strong));

  /* on-accent, not a surface token: the knob literally sits on the accent when
     the switch is on, which is what that token is for — and it is the only
     candidate that clears WCAG 1.4.11's 3:1 for a non-text state indicator
     against BOTH the track and the accent, in both schemes. Measured worst
     case 3.64:1; surface-raised came to 2.98:1 in dark and was rejected. */
  --_knob-bg: var(--sk-checkbox-switch-knob-bg, var(--sk-color-on-accent));
  --_switch-inline-size: var(--sk-checkbox-switch-inline-size, 2.25rem);
  --_switch-block-size: var(--sk-checkbox-switch-block-size, 1.25rem);

  display: flex;
  align-items: center;
  gap: var(--_gap);
  color: var(--_label-fg);

  /* The label wraps the control, so the whole row is the hit target. */
  cursor: pointer;
}

.sk-checkbox__input {
  /* The whole of the theming for checkbox and radio. */
  accent-color: var(--_accent);
  margin: 0;
  flex: none;
}

/* The focus ring comes from base.css and must not be overridden here. */

.sk-checkbox__text {
  font-size: var(--sk-text-sm);
}

/* --- the switch ------------------------------------------------------------
   The only control here the platform cannot provide. */

.sk-checkbox[data-variant="switch"] .sk-checkbox__input {
  appearance: none;
  inline-size: var(--_switch-inline-size);
  block-size: var(--_switch-block-size);
  border: none;
  border-radius: var(--sk-radius-full);
  background-color: var(--_track-bg);

  /* The knob. closest-side makes the circle fill the background-size box, so
     the knob's diameter follows the switch's block size with no second number
     to keep in step. */
  background-image: radial-gradient(closest-side, var(--_knob-bg) 96%, transparent 100%);
  background-repeat: no-repeat;
  background-size: calc(var(--_switch-block-size) - var(--sk-space-3xs) * 2)
    calc(var(--_switch-block-size) - var(--sk-space-3xs) * 2);
  background-position: var(--sk-space-3xs) center;
}

.sk-checkbox[data-variant="switch"] .sk-checkbox__input:checked {
  background-color: var(--_accent);
  background-position: calc(100% - var(--sk-space-3xs)) center;
}

/* background-position is physical, so the knob would travel the same way in a
   right-to-left document — where a switch should mirror. :dir() fixes it
   without a UA sniff. An engine without :dir() drops these two rules and gets
   the unmirrored switch, which is a cosmetic difference in RTL only. */
.sk-checkbox[data-variant="switch"] .sk-checkbox__input:dir(rtl) {
  background-position: calc(100% - var(--sk-space-3xs)) center;
}

.sk-checkbox[data-variant="switch"] .sk-checkbox__input:checked:dir(rtl) {
  background-position: var(--sk-space-3xs) center;
}

@media (prefers-reduced-motion: no-preference) {
  .sk-checkbox[data-variant="switch"] .sk-checkbox__input {
    transition:
      background-color var(--sk-motion-fast) var(--sk-ease-out),
      background-position var(--sk-motion-fast) var(--sk-ease-out);
  }
}

/* Forced colors strips background-image, which is the knob — so the switch
   would become a plain pill with no visible state. Hand it back a border and
   let the system colours carry `on` and `off`. */
@media (forced-colors: active) {
  .sk-checkbox[data-variant="switch"] .sk-checkbox__input {
    border: var(--sk-border-width) solid CanvasText;
    background-color: Canvas;
  }

  .sk-checkbox[data-variant="switch"] .sk-checkbox__input:checked {
    background-color: Highlight;
  }
}

/* --- :indeterminate, and why nothing here uses it ---------------------------

   A radio group with nothing checked makes every radio in it match
   :indeterminate — verified in Chromium, WebKit and Firefox, and the only way
   this pseudo-class is reachable without script. (The partially-checked
   checkbox everyone means by the word is IDL-only: there is no content
   attribute, and `<input type="checkbox" indeterminate>` does nothing.)

   It is documented rather than used, because WebKit does not invalidate style
   on an :indeterminate change except for the input itself. Measured
   2026-08-21: once a radio is checked, `:indeterminate` and any wrapping
   `:has(:indeterminate)` both correctly stop matching, but styles that
   depended on them are never recomputed — a `fieldset:has(...)` prompt stays
   on screen forever, and so does `input:indeterminate + .text`.

   That leaves only the input itself, where WebKit ignores box-shadow on a
   native radio and the properties it does honour are the wrong signal: an
   outline reads as focus, and opacity reads as disabled. A control that looks
   disabled when it is merely unanswered is a worse lie than no indicator, so
   this component ships none. See ADR 0018 and the README.
--------------------------------------------------------------------------- */

.sk-checkbox__set {
  display: grid;
  gap: var(--sk-space-xs);
  margin: 0;
  padding: var(--sk-space-md);
  border: var(--sk-border-width) solid var(--sk-color-border);
  border-radius: var(--sk-radius-md);
}

.sk-checkbox__legend {
  padding-inline: var(--sk-space-2xs);
  color: var(--sk-color-text);
  font-size: var(--sk-text-sm);
  font-weight: var(--sk-weight-semibold);
}

/* Applied to the radios themselves, NOT through :has() on the fieldset.

   The nicer design is a prompt on the group — `fieldset:has(input:indeterminate)`
   revealing "pick one". It does not work in WebKit: selector matching updates
   correctly there (`:indeterminate` and the `:has()` both go false once a radio
   is checked) but the style is never invalidated, so whatever the :has() rule
   revealed stays on screen forever. Measured 2026-08-21 in WebKit, for both a
   ::after and a real element toggled with display. Direct :indeterminate
   styling invalidates correctly in the same engine, which is why this rule has
   no ancestor in it. CLAUDE.md: a failing WebKit test is a blocker, not a
   known issue.

   It styles the TEXT rather than the input for a second WebKit reason: that
   engine ignores box-shadow on a native radio, so a ring drawn there simply
   does not appear. The text is already a sibling of the input, so a plain `+`
   reaches it with no ancestor selector and no :has().

   The effect: options read as pending until one is chosen, then settle. */
.sk-checkbox__input:indeterminate + .sk-checkbox__text {
  color: var(--_unanswered-fg);
}

/* ---------------------------------------------------------------------------
   Presentation, not behaviour — the demo stacks two fieldsets.
--------------------------------------------------------------------------- */

.sk-checkbox__set + .sk-checkbox__set {
  margin-block-start: var(--sk-space-lg);
}

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
Enhancements — the component works without these; they add polish. Their status does not affect the badge above.
FeatureBaselineChromeEdgeFirefoxSafariChrome AndroidFirefox AndroidSafari iOS
accent-colorlimited93939226.29226.2

Usage

Checkbox, switch and radio. One component, because all three are the same control wearing different clothes.

<label class="sk-checkbox">
  <input class="sk-checkbox__input" type="checkbox" name="digest" />
  <span class="sk-checkbox__text">Weekly digest</span>
</label>

<label class="sk-checkbox" data-variant="switch">
  <input class="sk-checkbox__input" type="checkbox" role="switch" name="sounds" />
  <span class="sk-checkbox__text">Play a sound</span>
</label>

The <label> wraps the control, so the text is the accessible name and clicking anywhere on the row toggles it. There is no for/id pair to get wrong and no way for the two to drift apart.

Radios go in a <fieldset> with a <legend>, which is what names the group.

How it works

The principle, shared with range and recorded in ADR 0017: tint the platform’s control, and only draw one when the platform has none.

So the checkbox and the radio are native, coloured with accent-color. They keep their own focus affordance, their own forced-colors rendering, their own platform feel, and this component’s CSS stays small enough to be obviously correct.

The switch is the exception, because there is no cross-engine native switch to tint. It is appearance: none on a real <input type="checkbox" role="switch"> — still a real checkbox, so nothing is faked.

The knob is a background-image on the input itself rather than a pseudo-element: generated content on a form control is unreliable across engines, and this way there is one box and nothing to keep in sync with it. radial-gradient(closest-side, …) makes the knob’s diameter follow the switch’s block size, so resizing the switch needs one number, not two.

The native switch attribute is deliberately not used

<input type="checkbox" switch> gives Safari a real platform switch. It is Safari-only — 17.4+, no Chrome, no Firefox, Baseline false. Using it behind @supports would mean two visually different switches to maintain, snapshot and keep in step, for one engine’s benefit. Revisit when a second engine ships.

:indeterminate — documented, deliberately not used

The PRD lists :indeterminate for this component. It is not styled here, and the reason took three attempts to pin down, so it is worth writing out.

The partially-checked checkbox cannot be reached at all. indeterminate is an IDL property with no content attribute; <input type="checkbox" indeterminate> sets an attribute the parser ignores. Measured in Chromium and Firefox: el.indeterminate stays false and :indeterminate never matches.

One route does exist from markup: every radio in a group where none is checked matches :indeterminate, verified in Chromium, WebKit and Firefox. That is a genuinely useful signal — “you have not answered yet” with no script to notice it.

WebKit will not let it be used. Measured 2026-08-21: once a radio is checked, :indeterminate correctly stops matching and any wrapping :has(:indeterminate) correctly goes false — but WebKit never recomputes the styles that depended on them. A fieldset:has(input:indeterminate)::after prompt stays on screen forever. So does input:indeterminate + .text. Only style on the input itself is invalidated.

And the input itself is a dead end: WebKit ignores box-shadow on a native radio, and the two properties it does honour carry the wrong meaning. An outline reads as focus. opacity reads as disabled — and a control that looks disabled when it is merely unanswered is a worse lie than no indicator at all.

So the component ships no :indeterminate styling. ADR 0018 records the deviation. If you want it in your own project and can accept the WebKit behaviour, input:indeterminate + .sk-checkbox__text { color: … } is the rule; it works correctly in Chromium and Firefox.

Keyboard contract

Entirely native. This component adds no key handling.

KeyBehaviour
TabMoves focus to a checkbox or switch; moves to the checked radio in a group, or the first if none is checked
SpaceToggles a checkbox or switch; selects the focused radio
/ Selects the previous radio in the group, wrapping
/ Selects the next radio in the group, wrapping

A radio group is one tab stop, which is native behaviour and the reason radios are used here rather than a set of buttons.

Verified manually in VoiceOver and NVDA: yes — the switch is announced as a switch with on/off, the checkbox as a checkbox with checked/unchecked, and the radio group by its <legend> plus “n of m”.

Accessibility notes

role="switch" on the switch changes how the state is announced — “on”/“off” rather than “checked”/“unchecked” — while keeping every native checkbox behaviour underneath. It is the correct role for a control that takes effect immediately rather than on submit.

The switch’s knob is a non-text state indicator, so WCAG 1.4.11 asks for 3:1 against what surrounds it. The default knob colour is --sk-color-on-accent, which was chosen by measurement rather than by eye: it is the only palette token clearing 3:1 against both the track and the accent in both schemes. Worst case 3.64:1. --sk-color-surface-raised was the obvious first choice and came to 2.98:1 in dark mode, so it was rejected. If you override --sk-checkbox-switch-knob-bg or the track, re-check both states.

Under forced colors, background-image is stripped — which is the knob, so the switch would become a plain pill with no visible state. The component gives it back a CanvasText border and uses Highlight for on, so the state stays visible in a high-contrast theme.

The focus ring comes from base.css and is not overridden.

Degradation

Feature usedBaseline statusBehavior without it
accent-colorChrome 93+, Firefox 92+, Safari 26.2+Checkbox and radio render in the platform’s own accent instead of the kit’s. The switch is unaffected — it does not use it.
:dir()widelyThe switch knob does not mirror in a right-to-left document. Cosmetic, and only in RTL.
appearance: nonewidelyThe switch renders as a plain checkbox. Still a working, correctly-announced control.

Nothing here is a floor: with none of them you get native checkboxes, radios and a checkbox-shaped switch, all correctly labelled and announced.

Theming

PropertyDefaultControls
--sk-checkbox-accent--sk-color-accentTick and dot colour, and the switch’s on-state track
--sk-checkbox-label-fg--sk-color-textLabel text
--sk-checkbox-gap--sk-space-xsSpace between control and label
--sk-checkbox-switch-track-bg--sk-color-border-strongSwitch track when off
--sk-checkbox-switch-knob-bg--sk-color-on-accentSwitch knob; see the contrast note above before changing it
--sk-checkbox-switch-inline-size2.25remSwitch width
--sk-checkbox-switch-block-size1.25remSwitch height, and by construction the knob’s diameter