Component
checkbox
Demo
tokens.css,
base.css and checkbox.css. No scripts.
Source
Copy both files, or run npx nojsui add checkbox.
Browser support
Works across current and earlier versions of every major engine.
| Feature | Baseline | Chrome | Edge | Firefox | Safari | Chrome Android | Firefox Android | Safari iOS |
|---|
| Feature | Baseline | Chrome | Edge | Firefox | Safari | Chrome Android | Firefox Android | Safari iOS |
|---|---|---|---|---|---|---|---|---|
| accent-color | limited | 93 | 93 | 92 | 26.2 | — | 92 | 26.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.
| Key | Behaviour |
|---|---|
Tab | Moves focus to a checkbox or switch; moves to the checked radio in a group, or the first if none is checked |
Space | Toggles 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 used | Baseline status | Behavior without it |
|---|---|---|
accent-color | Chrome 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() | widely | The switch knob does not mirror in a right-to-left document. Cosmetic, and only in RTL. |
appearance: none | widely | The 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
| Property | Default | Controls |
|---|---|---|
--sk-checkbox-accent | --sk-color-accent | Tick and dot colour, and the switch’s on-state track |
--sk-checkbox-label-fg | --sk-color-text | Label text |
--sk-checkbox-gap | --sk-space-xs | Space between control and label |
--sk-checkbox-switch-track-bg | --sk-color-border-strong | Switch track when off |
--sk-checkbox-switch-knob-bg | --sk-color-on-accent | Switch knob; see the contrast note above before changing it |
--sk-checkbox-switch-inline-size | 2.25rem | Switch width |
--sk-checkbox-switch-block-size | 1.25rem | Switch height, and by construction the knob’s diameter |