Selection
Switch
Toggles a single setting on or off, optionally with icons in its handle.
- COMPONENT_SPEC §24
- CSS · React
- Open in the playground
Examples
Switches
Plain, with icons in the handle, with the label first, and disabled. A native checkbox with role="switch": no JavaScript needed.
import { Switch } from '@kyralabs/m3exp-web/react';
export default function Switches() {
return (
<div style={{ display: 'grid', gap: 'var(--m3e-sys-space-200)' }}>
<Switch defaultSelected>Wi-Fi</Switch>
<Switch checkedIcon="check" uncheckedIcon="close">
Bluetooth
</Switch>
<Switch labelPosition="start" defaultSelected checkedIcon="check">
Airplane mode
</Switch>
<Switch isDisabled>Hotspot</Switch>
</div>
);
}
<div style="display: grid; gap: var(--m3e-sys-space-200)">
<label class="m3e-switch">
<input class="m3e-switch__input" type="checkbox" role="switch" checked />
<span class="m3e-switch__track" aria-hidden="true">
<span class="m3e-switch__handle">
<span class="m3e-switch__state"></span>
</span>
</span>
<span class="m3e-switch__label">Wi-Fi</span>
</label>
<label class="m3e-switch">
<input class="m3e-switch__input" type="checkbox" role="switch" />
<span class="m3e-switch__track" aria-hidden="true">
<span class="m3e-switch__handle">
<span class="m3e-icon m3e-switch__icon m3e-switch__icon--checked" data-fill>check</span>
<span class="m3e-icon m3e-switch__icon m3e-switch__icon--unchecked" data-fill>close</span>
<span class="m3e-switch__state"></span>
</span>
</span>
<span class="m3e-switch__label">Bluetooth</span>
</label>
<label class="m3e-switch">
<span class="m3e-switch__label">Airplane mode</span>
<input class="m3e-switch__input" type="checkbox" role="switch" checked />
<span class="m3e-switch__track" aria-hidden="true">
<span class="m3e-switch__handle">
<span class="m3e-icon m3e-switch__icon m3e-switch__icon--checked" data-fill>check</span>
<span class="m3e-switch__state"></span>
</span>
</span>
</label>
<label class="m3e-switch">
<input class="m3e-switch__input" type="checkbox" role="switch" disabled />
<span class="m3e-switch__track" aria-hidden="true">
<span class="m3e-switch__handle">
<span class="m3e-switch__state"></span>
</span>
</span>
<span class="m3e-switch__label">Hotspot</span>
</label>
</div>
Run the HTML version (CSS classes only).
API
React
<Switch>
Switch (COMPONENT_SPEC §24, Compose Switch): React Aria useSwitch / react-stately useToggleState on the m3e-css .m3e-switch contract (a native checkbox with role="switch": Space toggles, Enter does not). The ref is the native input. The handle grows on press and springs in CSS (switch.css); this drives the ripple, whose data-pressed also holds the pressed handle where :active does not follow React Aria's presses.
| Prop | Type | Default | Description |
|---|---|---|---|
children
|
ReactNode
|
The inline label (m3.material.io: switches always pair with one; the whole label toggles). | |
isSelected
|
boolean
|
Controlled checked state. | |
checkedIcon
|
string
|
Material Symbols icon shown in the handle while checked (Compose thumbContent; the
catalog's SwitchWithThumbIconSample shows check).
|
|
uncheckedIcon
|
string
|
Icon shown while unchecked (m3.material.io pairs close with check); grows the handle.
|
|
labelPosition
|
'start' | 'end'
|
The label before (start) or after (end, default) the switch.
|
|
className
|
string
|
||
defaultSelected
|
boolean
|
||
isDisabled
|
boolean
|
||
isReadOnly
|
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
|
CSS
switch.css
@import '@kyralabs/m3e-css/switch.css';
Switch (COMPONENT_SPEC §24): a native checkbox with role="switch", checked / unchecked, disabled, optional icons in the handle. No JavaScript needed: Space toggles (Enter does not), the press grows the handle through :active and release springs it back.
<label class="m3e-switch">
<input class="m3e-switch__input" type="checkbox" role="switch" checked />
<span class="m3e-switch__track" aria-hidden="true">
<span class="m3e-switch__handle">
<span class="m3e-icon m3e-switch__icon m3e-switch__icon--checked" data-fill>check</span>
<span class="m3e-icon m3e-switch__icon m3e-switch__icon--unchecked" data-fill>close</span>
<span class="m3e-switch__state"></span>
</span>
</span>
<span class="m3e-switch__label">Wi-Fi</span>
</label>
The icons are optional (Compose thumbContent): --checked shows only while checked, --unchecked only while unchecked, an icon without a modifier in both. The label text is optional (then name the input with aria-label) and may come first (before the input) for a leading label.
38 custom properties it reads
| Custom property | Default |
|---|---|
--m3e-comp-minimum-interactive-component-size
|
48px |
--m3e-comp-switch-handle-color-checked
|
var(--m3e-sys-color-on-primary) |
--m3e-comp-switch-handle-color-checked-disabled
|
color-mix(in srgb, var(--m3e-sys-color-surface) calc(1 * 100%), var(--m3e-sys-color-surface)) |
--m3e-comp-switch-handle-color-unchecked
|
var(--m3e-sys-color-outline) |
--m3e-comp-switch-handle-color-unchecked-disabled
|
color-mix(in srgb, var(--m3e-sys-color-on-surface) calc(0.38 * 100%), var(--m3e-sys-color-surface)) |
--m3e-comp-switch-handle-duration
|
var(--m3e-sys-motion-spring-fast-spatial-duration) |
--m3e-comp-switch-handle-easing
|
var(--m3e-sys-motion-spring-fast-spatial-easing) |
--m3e-comp-switch-handle-press-duration
|
0ms |
--m3e-comp-switch-handle-shape
|
var(--m3e-sys-shape-corner-full) |
--m3e-comp-switch-icon-color-checked
|
var(--m3e-sys-color-on-primary-container) |
--m3e-comp-switch-icon-color-checked-disabled
|
color-mix(in srgb, var(--m3e-sys-color-on-surface) calc(0.38 * 100%), var(--m3e-sys-color-surface)) |
--m3e-comp-switch-icon-color-unchecked
|
var(--m3e-sys-color-surface-container-highest) |
--m3e-comp-switch-icon-color-unchecked-disabled
|
color-mix(in srgb, var(--m3e-sys-color-surface-container-highest) calc(0.38 * 100%), var(--m3e-sys-color-surface)) |
--m3e-comp-switch-icon-size
|
16px |
--m3e-comp-switch-label-space
|
12px |
--m3e-comp-switch-label-text-font
|
var(--m3e-sys-typescale-body-large-font) |
--m3e-comp-switch-label-text-line-height
|
var(--m3e-sys-typescale-body-large-line-height) |
--m3e-comp-switch-label-text-size
|
var(--m3e-sys-typescale-body-large-size) |
--m3e-comp-switch-label-text-tracking
|
var(--m3e-sys-typescale-body-large-tracking) |
--m3e-comp-switch-label-text-weight
|
var(--m3e-sys-typescale-body-large-weight) |
--m3e-comp-switch-pressed-thumb-diameter
|
28px |
--m3e-comp-switch-state-layer-color
|
currentcolor |
--m3e-comp-switch-state-layer-shape
|
var(--m3e-sys-shape-corner-full) |
--m3e-comp-switch-state-layer-size
|
40px |
--m3e-comp-switch-thumb-diameter
|
24px |
--m3e-comp-switch-track-color-checked
|
var(--m3e-sys-color-primary) |
--m3e-comp-switch-track-color-checked-disabled
|
color-mix(in srgb, var(--m3e-sys-color-on-surface) calc(0.12 * 100%), var(--m3e-sys-color-surface)) |
--m3e-comp-switch-track-color-unchecked
|
var(--m3e-sys-color-surface-container-highest) |
--m3e-comp-switch-track-color-unchecked-disabled
|
color-mix(in srgb, var(--m3e-sys-color-surface-container-highest) calc(0.12 * 100%), var(--m3e-sys-color-surface)) |
--m3e-comp-switch-track-height
|
32px |
--m3e-comp-switch-track-outline-color-checked
|
transparent |
--m3e-comp-switch-track-outline-color-checked-disabled
|
transparent |
--m3e-comp-switch-track-outline-color-unchecked
|
var(--m3e-sys-color-outline) |
--m3e-comp-switch-track-outline-color-unchecked-disabled
|
color-mix(in srgb, var(--m3e-sys-color-on-surface) calc(0.12 * 100%), var(--m3e-sys-color-surface)) |
--m3e-comp-switch-track-outline-width
|
2px |
--m3e-comp-switch-track-shape
|
var(--m3e-sys-shape-corner-full) |
--m3e-comp-switch-track-width
|
52px |
--m3e-comp-switch-unchecked-thumb-diameter
|
16px |
Accessibility
native <input type="checkbox" role="switch"> (aria-checked comes from checked; label association; works without JS). Space toggles; Enter does not (owner decision M8; m3.material.io says Space or Enter).
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 §24 and the m3e-css sources.
SwitchTokens.kt@19991f2c plus the Switch.kt / SwitchDefaults values Compose renders (components/switch-defaults.css; SOURCES register #41): a 52 × 32dp track (TrackWidth × TrackHeight, full shape) with a 2dp outline (TrackOutlineWidth) drawn over the handle, in a 48dp-high target (minimum interactive size). Handle (ThumbNode.measure): 16dp unchecked (UnselectedHandleWidth), 24dp checked or with an icon (ThumbDiameter = SelectedHandleWidth; IconHandleWidth, also 24dp, is not read), 28dp pressed (PressedHandleWidth); offset unchecked (32 − size) / 2, checked (52 − 24) − 4 = 24dp, pressed 2dp in from either end. Icons (thumbContent) 16dp (SwitchDefaults.IconSize), centred. Colors (defaultSwitchColors): checked track primary, handle on-primary, icon on-primary-container, no outline; unchecked track surface-container-highest, handle and outline outline, icon surface-container-highest; disabled colors are copy(alpha).compositeOver(surface) in Compose, opaque color-mix(in srgb, <color> <alpha>%, surface) here (checked: handle surface, track on-surface 12%, icon on-surface 38%; unchecked: handle on-surface 38%, track surface-container-highest 12%, outline on-surface 12%, icon surface-container-highest 38%). Hover, focus and press change no handle or icon color: the tokens' {Selected,Unselected}{Hover,Focus,Pressed}{Handle,Icon}Color are never rendered by Compose, so only the state layer shows (owner decision M8): the 40dp circle centred on the handle (an unbounded ripple of radius 20dp on the thumb, moving with it) in the content color (Compose's ripple color). Focus: the state layer's focus opacity plus a ring around the track (Compose's inset-ring focusRingShape = trackShape). Label: m3.material.io asks for an inline label; Compose has no sample, so the expressive ListItem's 12dp spacing before trailing content and its body large label (owner's pick, D19 (7); SOURCES register #67), in the content color, before or after the switch; the whole label toggles.
switch.css sources
(compose/material3 @ 19991f2c, Switch.kt; values in switch.css / switch-defaults.css, lines in PARITY.md):
- Layout (SwitchImpl L136-211): clickable → minimumInteractiveComponentSize (48dp high around the 52 × 32dp track; the input covers the whole label here), toggleable (Role.Switch); the track is SwitchWidth × SwitchHeight (TrackWidth × TrackHeight), its background then a TrackOutlineWidth (2dp) outline drawn over its content (border after background), so the outline is the track's ::after, over the handle. The thumb is aligned CenterStart and placed at its offset from the start (placeRelative: mirrored in RTL).
- Handle (ThumbNode.measure L279-327): pressed → PressedHandleWidth (28dp); thumbContent or checked → ThumbDiameter (SelectedHandleWidth, 24dp; IconHandleWidth is not read); else UncheckedThumbDiameter (16dp). Offset: unchecked (TrackHeight − size) / 2, checked (TrackWidth − ThumbDiameter) − ThumbPadding (= (TrackHeight − ThumbDiameter) / 2), pressed TrackOutlineWidth in from the start (unchecked) or that less from the checked offset.
- Motion (L188, L307-317): offset and size on FastSpatial (they overshoot under the expressive scheme), but SnapSpec while pressed: the handle jumps to 28dp on press and springs back (or across, when the press toggles) on release. Here inset-inline-start and the size transition with the spring's linear() easing, and with no duration while pressed. Colors are plain backgrounds (L176-200): they do not animate. Reduced motion: the springs' durations are 0.
- Colors: defaultSwitchColors (L426-470); the disabled ones are Color.copy(alpha) .compositeOver(surface), opaque color-mix()es over surface (switch-defaults.css). Hover, focus and press change no handle color (SwitchTokens' {Selected,Unselected}{Hover,Focus,Pressed} HandleColor are never rendered by Compose): state layer only (owner decision M8 #3).
- State layer (L191-199): an unbounded ripple on the thumb, radius StateLayerSize / 2, in the content color (owner decision M8 #4): the 40dp circle centred on the handle, moving with it. Focus: that layer's focus opacity plus a ring around the track (Compose's inset-ring focusRingShape = trackShape).
- Icons (SwitchWithThumbIconSample): Material Symbols (the Icon primitive), SwitchDefaults .IconSize (16dp), in iconColor, centred in the handle; Compose's Icons.Filled → FILL 1.
- Label: m3.material.io asks for an inline label (Switch › Guidelines); Compose has none, so the expressive ListItem's spacing before trailing content and its label style (body large), in the content color (m3.material.io: on surface). Owner's pick: D19 (7), SOURCES register #67.