Checkbox
Checkboxes capture an independent on/off setting, or a partially-selected group.
Components · Form
A checkbox represents a single, independent binary setting — on or off — and can additionally represent a mixed state when it summarizes a partially-selected group. Namirial checkboxes are built from --nm-checkbox-* tokens defined in namirial-theme.css, ship in two sizes, and pair with the shared .nm-choice-field label wrapper used by radio and switch.
Overview
A checkbox lets a user opt in or out of one thing at a time. Each checkbox in a list is independent — checking one never unchecks another. Use the mixed state only on a "select all" checkbox that summarizes a group of children where some, but not all, are checked.
When to use
Independent selection:
A single, standalone yes/no setting (Remember me, Send me product updates), or a multi-select list where any number of items — including zero or all — can be checked. Not for mutually exclusive options; use a radio group for that instead.
Select all / mixed state:
A "select all" control that reflects the state of the group beneath it — off, on, or mixed when only some children are checked.
Staged, not instant:
Checkboxes imply the change is queued until the surrounding form is submitted, rather than applying right away. If the setting is always-visible in a settings page and should take effect the instant it's flipped, use a switch instead — it reads more clearly at a glance for that case.
Required state
Show that a checkbox is required before validation, not only after — via a visible marker or short instruction text.
- Required list (e.g. "select at least one"): show the validation message on the list as a whole, not per item.
- Required single checkbox (e.g. "I agree to the terms"): show the validation message on that checkbox.
- If no explicit confirming action is legally or functionally needed, consider a plain info text instead of a checkbox — it saves a click while still making sure the user has seen the requirement.
Anatomy
A checkbox is a single <input type="checkbox"> styled with appearance: none. Everything — box, border, and check glyph — is one element; the glyph is an inline SVG rendered as a mask-image on its ::after pseudo-element, not a separate icon component. When labeled, it's wrapped in a .nm-choice-field <label> alongside the text.
Tokens and values that drive each part
| Part | Role | Source |
|---|---|---|
| Container | Fill, unchecked | --nm-checkbox-bg |
| Container | Fill, checked / mixed | --nm-checkbox-checked-bg |
| Container | Size | width / height — 24px default, 20px --sm |
| Container | Radius | border-radius — 8px default, 6px --sm |
| Border | Color, unchecked | --nm-checkbox-border |
| Border | Color, checked / mixed | --nm-checkbox-checked-border |
| Check glyph | Color | --nm-checkbox-checked-icon |
| Check glyph | Shape | Checkmark when :checked, dash when :indeterminate — swapped mask-image |
| Label | Typography | .nm-choice-field__label — 16px / 400 / Open Sans |
| Required mark | Color | --nm-choice-field-required |
| Optional mark | Color | --nm-choice-field-optional |
| Focus ring | Color | --palette-focus-700 (light) / --palette-focus-300 (dark), 2px outline, 2px offset |
Selection states
A checkbox has three possible selection states. Off and On are set by the user; Mixed can only be set programmatically — there's no way for a person to click a checkbox into "mixed" directly.
Wiring "select all"
Mixed is a visual and semantic state, not a click target — set it in code from your list's current selection:
const allChecked = items.every((i) => i.checked);
const noneChecked = items.every((i) => !i.checked);
selectAll.indeterminate = !allChecked && !noneChecked;
selectAll.checked = allChecked;
selectAll.setAttribute('aria-checked', selectAll.indeterminate ? 'mixed' : String(allChecked));Clicking a mixed checkbox should select all children, not toggle each one individually — treat it the same as clicking an unchecked box.
Sizes
Checkbox ships in two sizes — most surfaces should default to 24px and reserve 20px for dense tables and compact filter lists.
Interaction states
Every checkbox responds to hover and can be placed in a read-only state that keeps its value visible but locks it against further changes.
All shown checked
No disabled state
Like buttons, Namirial checkboxes never ship a browser-default "greyed out and dead" disabled look. If a setting truly can't be changed right now, use read-only instead: the value stays legible, the control stays in the layout, and — unlike disabled — its value still submits with the form.
Read-only implementation
The readonly attribute has no native effect on <input type="checkbox">, so the .is-readonly class does the visual work while you pair it with tabIndex={-1} and aria-readonly="true" to make it genuinely non-interactive:
<input type="checkbox" class="nm-checkbox is-readonly" checked readonly tabindex="-1" aria-readonly="true">pointer-events: none (baked into .is-readonly) blocks clicks; tabindex="-1" removes it from the tab order so Space/Enter can't reach it either.
Focus
Focus is rendered by a 2px outline, 2px offset, using the same focus color as the rest of the system. Never suppress the focus ring — it's the only way keyboard users can tell which checkbox is about to be toggled.
Markup
Pair a checkbox with a label using .nm-choice-field. The label is clickable and toggles the input — you don't need a separate for/id wire-up as long as the input is a descendant of the <label>.
Bare · required · optional
Use .nm-choice-field__required for a setting the user must actively confirm before continuing (rare for checkboxes — most are opt-in by nature). Use .nm-choice-field__optional to reassure the user that leaving it unchecked is fine.
Light & dark theme
Checkbox tokens are remapped under .dark. Contrast and hover directionality are handled for you.
Both previews are locked — they don't follow the page theme switcher.
Do & don't
Accessibility
Semantics
- Always wrap the input in a
<label>(or connect it viafor/id) so the hit target includes the text. - Group related checkboxes under a
<fieldset>with a<legend>when they belong to the same question.
Mixed state
- Set
element.indeterminate = truein JavaScript — there is no HTML attribute for it. - Also set
aria-checked="mixed"explicitly. Some screen readers don't reliably announce the nativeindeterminateproperty on its own.
Read-only
- Native
readonlyhas no effect on checkboxes. Pair.is-readonlywithtabindex="-1"andaria-readonly="true"— this removes it from the tab order (so Space can't reach it) whilepointer-events: noneblocks clicks, and the field's value still submits with the form.
Keyboard
- Tab moves focus between checkboxes; Space toggles the focused one.
- Focus uses
:focus-visible, so the ring appears on keyboard focus but not on mouse click.
Labeling
- Never rely on color alone to communicate checked vs. unchecked — the filled container plus the check glyph both change.
- If a checkbox has no visible text label (rare), it must have an
aria-label.
Playground
Token reference
| Token | Role |
|---|---|
| --nm-checkbox-bg | Container fill, unchecked |
| --nm-checkbox-border | Border, unchecked |
| --nm-checkbox-checked-bg | Container fill, checked / mixed |
| --nm-checkbox-checked-border | Border, checked / mixed |
| --nm-checkbox-checked-icon | Check / dash glyph color |
| --nm-checkbox-hover-bg | Fill on hover, unchecked |
| --nm-checkbox-hover-border | Border on hover, unchecked |
| --nm-checkbox-checked-hover-bg | Fill on hover, checked / mixed |
| --nm-checkbox-checked-hover-icon | Glyph color on hover, checked / mixed |
| --nm-checkbox-readonly-bg | Fill, read-only + unchecked |
| --nm-checkbox-checked-readonly-bg | Fill, read-only + checked / mixed |
| --nm-checkbox-checked-readonly-icon | Glyph color, read-only + checked / mixed |
| --nm-choice-field-label-fg | Label text color |
| --nm-choice-field-required | Required mark color |
| --nm-choice-field-optional | Optional mark color |
Implementation
No JavaScript component is required for a plain checkbox — it's a native <input> styled by CSS classes. You only need JS to set indeterminate, or to enforce read-only.
<link rel="stylesheet" href="namirial-theme.css">
<label class="nm-choice-field">
<input type="checkbox" class="nm-checkbox">
<span class="nm-choice-field__label">
I agree to the terms of service
<span class="nm-choice-field__required">(required)</span>
</span>
</label>CSS classes
| Class | Values | Default |
|---|---|---|
nm-checkbox--sm | 20px variant (omit for 24px default) | default |
:checked | Selection = on | — |
:indeterminate | Selection = mixed (set via JS, not an attribute) | — |
[readonly] / .is-readonly | Read-only — pair with tabindex="-1" | — |
nm-choice-field__required | Marks a field as required | — |
nm-choice-field__optional | Marks a field as optional | — |