Selection
Checkbox
Lets people select one or more items from a list, or turn an option on or off, with indeterminate and error states.
- COMPONENT_SPEC §22
- CSS · custom elements · React
- Open in the playground
Examples
States
Unchecked, checked, indeterminate, invalid and disabled. Indeterminate is a property, not an attribute, which is why the element exists.
The live demo runs with JavaScript. Its code is below.
import { Checkbox } from '@kyralabs/m3exp-web/react';
export default function States() {
return (
<div style={{ display: 'grid' }}>
<Checkbox>Unchecked</Checkbox>
<Checkbox defaultSelected>Checked</Checkbox>
<Checkbox isIndeterminate>Indeterminate</Checkbox>
<Checkbox isInvalid>Invalid</Checkbox>
<Checkbox isDisabled defaultSelected>
Disabled
</Checkbox>
</div>
);
}
<div style="display: grid">
<m3e-checkbox>Unchecked</m3e-checkbox>
<m3e-checkbox checked>Checked</m3e-checkbox>
<m3e-checkbox indeterminate>Indeterminate</m3e-checkbox>
<m3e-checkbox invalid>Invalid</m3e-checkbox>
<m3e-checkbox checked disabled>Disabled</m3e-checkbox>
</div>
Run the HTML version (custom elements, no framework).
API
React
<Checkbox>
Checkbox (COMPONENT_SPEC §22): React Aria useCheckbox / react-stately useToggleState on the m3e-css .m3e-checkbox contract, the same DOM as <m3e-checkbox>. The ref is the native input. The check draws and morphs in CSS (checkbox.css); this writes data-from (the previous state, so indeterminate from unchecked draws the dash) and the ripple.
| Prop | Type | Default | Description |
|---|---|---|---|
children
|
ReactNode
|
The label (Compose's CheckboxWithTextSample row: the whole label toggles). | |
isSelected
|
boolean
|
Controlled checked state. | |
isIndeterminate
|
boolean
|
Shows the dash (Compose TriStateCheckbox ToggleableState.Indeterminate).
|
|
isInvalid
|
boolean
|
Error colors (aria-invalid).
|
|
className
|
string
|
||
defaultSelected
|
boolean
|
||
isDisabled
|
boolean
|
||
isReadOnly
|
boolean
|
||
isRequired
|
boolean
|
||
name
|
string
|
||
value
|
string
|
||
id
|
string
|
||
autoFocus
|
boolean
|
||
aria-label
|
string
|
||
aria-labelledby
|
string
|
||
aria-describedby
|
string
|
||
aria-controls
|
string
|
||
onChange
|
(isSelected: boolean) => void
|
Called with the new checked state. | |
onPressStart
|
(e: PressEvent) => void
|
||
onPressEnd
|
(e: PressEvent) => void
|
||
onFocus
|
(e: React.FocusEvent) => void
|
||
onBlur
|
(e: React.FocusEvent) => void
|
Custom elements
<m3e-checkbox>
import '@kyralabs/m3e-elements/checkbox.js';
<m3e-checkbox checked indeterminate disabled invalid required name value label>Label</m3e-checkbox> (COMPONENT_SPEC §22): renders the m3e-css checkbox inside itself (the host is display: contents) around a native <input type="checkbox">, which takes part in forms and fires the native input / change events. It exists for what HTML cannot declare:
indeterminateis only an input property; the attribute sets it.data-fromon the.m3e-checkboxroot keeps the previous state, so a checkbox that turns indeterminate from unchecked draws the dash instead of morphing the check (Compose snaps the gravitation there).- The ripple (the 40dp circle, pressed from the label or the input; Space only).
The text content is the label (else name it with label, the input's aria-label). invalid sets aria-invalid (error colors), required aria-required. A user toggle updates checked and clears indeterminate, as the native input does.
| Attribute | Property | Type | Notes |
|---|---|---|---|
checked
|
checked
|
boolean
|
|
indeterminate
|
indeterminate
|
boolean
|
|
disabled
|
disabled
|
boolean
|
|
invalid
|
invalid
|
boolean
|
|
required
|
required
|
boolean
|
|
name
|
name
|
string
|
|
value
|
value
|
string
|
|
label
|
label
|
string
|
CSS
checkbox.css
@import '@kyralabs/m3e-css/checkbox.css';
Checkbox (COMPONENT_SPEC §22): a native checkbox, checked / unchecked / indeterminate, error, disabled. No JavaScript needed (the native indeterminate property is the one thing HTML cannot declare: set it from script, or use <m3e-checkbox indeterminate> / m3e-react).
<label class="m3e-checkbox">
<input class="m3e-checkbox__input" type="checkbox" />
<span class="m3e-checkbox__box" aria-hidden="true">
<svg class="m3e-checkbox__mark" focusable="false">
<path class="m3e-checkbox__stroke" d="M0 0H48" /><path class="m3e-checkbox__stroke" d="M0 0H48" />
</svg>
</span>
<span class="m3e-checkbox__state" aria-hidden="true"></span>
<span class="m3e-checkbox__label">Option selection</span>
</label>
The label text is optional (then name the input with aria-label). Error: aria-invalid="true" on the input. The JS layers also write data-from="<previous state>" on the root, so a checkbox that becomes indeterminate from unchecked draws the dash instead of morphing the check (below).
54 custom properties it reads
| Custom property | Default |
|---|---|
--m3e-comp-checkbox-border-color-checked
|
var(--m3e-sys-color-primary) |
--m3e-comp-checkbox-border-color-unchecked
|
var(--m3e-sys-color-on-surface-variant) |
--m3e-comp-checkbox-border-color-unchecked-disabled
|
var(--m3e-sys-color-on-surface) |
--m3e-comp-checkbox-border-opacity-unchecked-disabled
|
0.38 |
--m3e-comp-checkbox-box-color-checked
|
var(--m3e-sys-color-primary) |
--m3e-comp-checkbox-box-color-checked-disabled
|
var(--m3e-sys-color-on-surface) |
--m3e-comp-checkbox-box-corner-radius
|
2px |
--m3e-comp-checkbox-box-opacity-checked-disabled
|
0.38 |
--m3e-comp-checkbox-box-size
|
20px |
--m3e-comp-checkbox-box-size-styling-fix
|
18px |
--m3e-comp-checkbox-check-cross-x
|
0.4 |
--m3e-comp-checkbox-check-cross-y
|
0.7 |
--m3e-comp-checkbox-check-cross-y-styling-fix
|
0.65 |
--m3e-comp-checkbox-check-duration
|
var(--m3e-sys-motion-spring-default-spatial-duration) |
--m3e-comp-checkbox-check-easing
|
var(--m3e-sys-motion-spring-default-spatial-easing) |
--m3e-comp-checkbox-check-gravitation-center
|
0.5 |
--m3e-comp-checkbox-check-left-x
|
0.2 |
--m3e-comp-checkbox-check-left-x-styling-fix
|
0.25 |
--m3e-comp-checkbox-check-left-y
|
0.5 |
--m3e-comp-checkbox-check-right-x
|
0.8 |
--m3e-comp-checkbox-check-right-x-styling-fix
|
0.75 |
--m3e-comp-checkbox-check-right-y
|
0.3 |
--m3e-comp-checkbox-checkmark-color
|
var(--m3e-sys-color-on-primary) |
--m3e-comp-checkbox-checkmark-color-disabled
|
var(--m3e-sys-color-on-primary) |
--m3e-comp-checkbox-checkmark-color-disabled-styling-fix
|
var(--m3e-sys-color-surface) |
--m3e-comp-checkbox-color-in-duration
|
var(--m3e-sys-motion-spring-default-effects-duration) |
--m3e-comp-checkbox-color-in-easing
|
var(--m3e-sys-motion-spring-default-effects-easing) |
--m3e-comp-checkbox-color-out-duration
|
var(--m3e-sys-motion-spring-fast-effects-duration) |
--m3e-comp-checkbox-color-out-easing
|
var(--m3e-sys-motion-spring-fast-effects-easing) |
--m3e-comp-checkbox-container-color-selected-error
|
var(--m3e-sys-color-error) |
--m3e-comp-checkbox-follow-duration
|
1ms |
--m3e-comp-checkbox-icon-color-selected-error
|
var(--m3e-sys-color-on-error) |
--m3e-comp-checkbox-label-space
|
16px |
--m3e-comp-checkbox-label-text-font
|
var(--m3e-sys-typescale-body-large-font) |
--m3e-comp-checkbox-label-text-line-height
|
var(--m3e-sys-typescale-body-large-line-height) |
--m3e-comp-checkbox-label-text-size
|
var(--m3e-sys-typescale-body-large-size) |
--m3e-comp-checkbox-label-text-tracking
|
var(--m3e-sys-typescale-body-large-tracking) |
--m3e-comp-checkbox-label-text-weight
|
var(--m3e-sys-typescale-body-large-weight) |
--m3e-comp-checkbox-outline-color-unselected-error
|
var(--m3e-sys-color-error) |
--m3e-comp-checkbox-snap-delay
|
100ms |
--m3e-comp-checkbox-snap-easing
|
steps(1, jump-end) |
--m3e-comp-checkbox-state-layer-color
|
currentcolor |
--m3e-comp-checkbox-state-layer-color-checked-styling-fix
|
var(--m3e-sys-color-primary) |
--m3e-comp-checkbox-state-layer-color-error-focus
|
var(--m3e-sys-color-error) |
--m3e-comp-checkbox-state-layer-color-error-hover
|
var(--m3e-sys-color-error) |
--m3e-comp-checkbox-state-layer-color-error-pressed
|
var(--m3e-sys-color-error) |
--m3e-comp-checkbox-state-layer-color-unselected-focus
|
var(--m3e-sys-color-on-surface) |
--m3e-comp-checkbox-state-layer-color-unselected-hover
|
var(--m3e-sys-color-on-surface) |
--m3e-comp-checkbox-state-layer-color-unselected-pressed
|
var(--m3e-sys-color-primary) |
--m3e-comp-checkbox-state-layer-shape
|
var(--m3e-sys-shape-corner-full) |
--m3e-comp-checkbox-state-layer-size
|
40px |
--m3e-comp-checkbox-stroke-width
|
2px |
--m3e-comp-checkbox-styling-fix
|
1 |
--m3e-comp-minimum-interactive-component-size
|
48px |
Accessibility
native <input type="checkbox"> (works without JS; label association; Space toggles); indeterminate is the input property; error = aria-invalid, required = aria-required (React Aria's default validation behavior).
Every interactive part has a target of at least 48 × 48 px, a visible focus ring for
keyboard focus only, and works under forced-colors and
prefers-reduced-motion.
Sources
Values come from androidx Compose Material3 1.5.0-beta01 at 19991f2c and from
m3.material.io, as cited below from COMPONENT_SPEC §22 and the m3e-css sources.
CheckboxTokens.kt@19991f2c plus the Checkbox.kt / CheckboxDefaults values Compose renders (components/checkbox-defaults.css; SOURCES register #39). ComposeMaterial3Flags.isCheckboxStylingFixEnabled (false at the pin) picks between two stylings; both are generated and --m3e-comp-checkbox-styling-fix selects one. Owner decision (M8): flag on (1), which matches the tokens and m3.material.io: 18dp box (CheckboxTokens.ContainerSize; flag off: 20dp), 2dp corners, 2dp stroke (CheckboxDefaults.StrokeWidth) with square caps, check path left (0.25, 0.5) → cross (0.4, 0.65) → right (0.75, 0.3) of the box (flag off: 0.2 / 0.7 / 0.8), centred in the 48dp minimum interactive size; 40dp state layer circle (StateLayerSize, unbounded ripple of radius 20dp from the centre). Colors (defaultCheckboxColors): checked / indeterminate box and border primary, checkmark on-primary; unchecked border on-surface-variant, box transparent; disabled checked / indeterminate on-surface at 0.38 with a surface checkmark (flag off: on-primary), disabled unchecked border on-surface at 0.38. Ripple color = the box color when checked / indeterminate (indicatorColor: primary, error with an error; flag off: the content color). While unchecked Compose's flag-on ripple is transparent (no state layer); owner decision (2026-10-17, amending M8 #4 for unchecked only): the m3.material.io token DB colors instead (TOKEN_TABLE.31f16b44fe03b0c0: md.comp.checkbox.unselected.{hover,focus}.state-layer.color on-surface, .unselected.pressed primary; with an error md.comp.checkbox.error.{hover,focus,pressed}.state-layer.color error), at the shared state layer opacities. The DB's selected pressed color (on-surface) differs from Compose's box color and is not applied. Error (not a Compose parameter; owner decision M8): CheckboxTokens Selected/UnselectedError* (error box / outline, on-error checkmark). Hover, focus and press change no box or outline color: the tokens' Unselected{Hover,Focus,Pressed}OutlineColor (on-surface) are never rendered by Compose, so only the state layer shows (owner decision M8). Label: Compose's CheckboxWithTextSample, 16dp after the 48dp checkbox, body large, content color; the whole label toggles and shows the checkbox's ripple.
checkbox.css sources
(compose/material3 @ 19991f2c, Checkbox.kt; values in checkbox.css / checkbox-defaults.css, lines in PARITY.md):
- ComposeMaterial3Flags.isCheckboxStylingFixEnabled (L41-49 there) picks between two stylings. --m3e-comp-checkbox-styling-fix mixes them (0 = Compose's default, flag off: 20dp box, check points 0.2 / 0.7 / 0.8, disabled checkmark on-primary, ripple in the content color; 1 = flag on: CheckboxTokens.ContainerSize 18dp, 0.25 / 0.65 / 0.75, disabled checkmark surface, ripple in the box color, transparent while unchecked). Owner decision (M8): 1, which matches the token file and m3.material.io. One declaration (e.g. on :root) switches it back.
- State layer while unchecked (owner decision 2026-10-17, amending M8 #4; flag on only): not Compose's transparent ripple but the m3.material.io token DB's per-state colors (md.comp.checkbox.unselected.{hover,focus}.state-layer.color on-surface, .pressed primary; with aria-invalid md.comp.checkbox.error.* error). Checked / indeterminate keep the box color.
- Layout (L554-593): clickable → minimumInteractiveComponentSize (48dp, the input here), triStateToggleable (Role.Checkbox), the box centred in it. Unbounded ripple of radius StateLayerSize / 2 (L554-560): the 40dp state layer circle, the press grows from its centre.
- drawBox (L675-701): one fill when box == border color, else the fill inside the outline and the outline: a border-box with a padding-box background here (outer radius 2dp, inner 0).
- drawCheck (L704-737): the path left → cross → right, its points lerped towards the centre by the gravitation fraction (indeterminate: a straight dash), drawn up to length × the draw fraction (PathMeasure.getSegment) with square caps. Here an inline SVG with one path per segment, placed and turned by CSS (atan2() of the gravitated points) and drawn by stroke-dashoffset; at the check's right angle the two square-capped segments cover exactly the miter join. The mark is an animated indicator, not a system glyph icon (owner decision M8), so CLAUDE.md's Icon rule does not apply. It is not mirrored in RTL (Compose draws it in canvas coordinates).
- Motion (L595-631): draw fraction and gravitation on DefaultSpatial (registered properties on each stroke, transitioned with the spring's linear() easing); towards unchecked both snap after SnapAnimationDelay (a one-step easing over the delay); leaving unchecked the gravitation snaps (data-from='unchecked' here). The strokes' 1ms "follow" transitions only make WebKit repaint them while the fractions animate (checkbox-defaults.css follow-duration). Colors (L633-650, L1040-1049): DefaultEffects in, FastEffects out; box and border snap while disabled. The checkmark color goes from Color.Transparent to its color and back, so it fades by opacity here (one layer: the two segments overlap at the joint).
- Colors: defaultCheckboxColors (L446-476); indeterminate = checked. Error is not a Compose parameter: the CheckboxTokens Selected/UnselectedError* colors (owner decision M8). Hover, focus and press change no box or outline color (the tokens' Unselected{Hover,Focus,Pressed} OutlineColor are never rendered by Compose): state layer only.
- Label: CheckboxWithTextSample (CheckboxSamples.kt L62-89): 16dp after the 48dp checkbox, bodyLarge, in the content color; the whole row toggles and shows the checkbox's ripple.