Skip to content

Selection

Switch

Toggles a single setting on or off, optionally with icons in its handle.

Examples

Switches

Plain, with icons in the handle, with the label first, and disabled. A native checkbox with role="switch": no JavaScript needed.

React HTML
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.