Skip to content

Switch

Switch · role=switch, aria-checked

An on/off toggle distinct from a checkbox, reporting its state as “on” or “off”.

Live example

Real and interactive — use it with the mouse, or Tab to it and use the keys below.

Email notifications
SMS notifications

Click either track, or Tab to it and press Space or Enter — both switch it the same way. The “On”/“Off” caption always matches what a screen reader hears from aria-checked; it never says something different from the actual state.

Keyboard

KeyAction
Tab / Shift + Tabmoves focus onto the switch, showing the same visible focus ring every control on this site uses
Enter or Spacetoggles the switch between on and off — both keys work because the switch is a real <button>, which treats them as a click natively

Screen reader

  • The switch is announced by its accessible name (from aria-labelledby, pointing at the adjacent visible label) followed by its state, “on” or “off” — not “checked”/“not checked”, which is what a checkbox would say.
  • role="switch" is announced explicitly, distinguishing it from a checkbox even though both are two-state controls.
  • Toggling it — by click, Space, or Enter — immediately re-announces the new state, because aria-checked changes on the same element the screen reader is already tracking.
  • An optional visible “On”/“Off” caption next to the track is marked aria-hidden, so it is never read as a second, potentially conflicting announcement alongside aria-checked.

ARIA notes

  • role="switch" is used, not role="checkbox" and not a bare <input type="checkbox"> with no role — a switch reports an immediate on/off state change, semantically distinct from a checkbox's checked/unchecked membership state, and the two are easy to conflate.
  • aria-checked (true/false) is driven by exactly one boolean prop that also positions the thumb — there is no second piece of state that could disagree with it, the same discipline this library already applies to Toast's live region and MenuButton's aria-expanded.
  • The component is a native <button>, not <input type="checkbox" role="switch">, so Enter and Space both activate it for free from the platform's own button semantics — no hand-written key handler exists that could implement only one of the two.
  • The visible label is linked via aria-labelledby to an adjacent <span>, not wrapped inside the button — the accessible name is still correct, but the button's hit target stays exactly the track's bounds rather than silently growing to include the label text.
  • The on/off state is never color-only (WCAG 1.4.1): the thumb's track position (left vs. right) is a shape/position difference on its own, and the color change on the track is additional reinforcement, not the only signal.

Code

The real source of the example above — copy it and it works. This is the file that renders on this page, so the code and the live example can never drift apart.

src/components/library/Switch.tsx
import { useId } from 'react'

// Accessible on/off toggle — the WAI-ARIA APG "Switch" pattern. The simplest
// widget in this library, but the one whose most common real-world bug is a
// semantic mix-up rather than a missing keyboard handler:
//
//   SWITCH IS NOT A CHECKBOX. A checkbox reports "checked / not checked" — a
//   member of a set, or an agreement to a statement. A switch reports
//   "on / off" — an immediate state change, usually with an immediate
//   effect (a setting flips right now, no form submission pending). Giving a
//   switch `role="checkbox"`, or leaving a bare `<input type="checkbox">`
//   with no role at all, tells a screen reader to announce "checked", which
//   is the wrong word for what just happened. `role="switch"` is what fixes
//   that — it is an ARIA role in its own right, not a checkbox variant.
//
//   This component uses a native <button role="switch">, not
//   <input type="checkbox" role="switch">, precisely to avoid that mix-up by
//   construction: there is no underlying checkbox semantics to override, and
//   no way to accidentally ship the input without its role. It also means
//   Enter and Space both activate it for free — a real <button> already
//   treats both as "click" natively, so there is no hand-written key handler
//   that could get only one of the two right. (A checkbox-based switch is
//   the APG's other listed variant and would only need Space; a switch
//   accepting Enter too is intentional here, matching a <button>'s default.)
//
//   `aria-checked` is the single source of truth for state, on this element
//   and nowhere else — there is exactly one boolean prop (`checked`) driving
//   both `aria-checked` and the thumb's position, so the two can never draw
//   from different state and drift apart the way Toast's and MenuButton's
//   own "don't let the announced state and the visual state disagree"
//   pitfalls warn about for their patterns.
//
// LABEL ASSOCIATION: the visible label is rendered as an adjacent <span>,
// linked with `aria-labelledby`, not wrapped inside the <button> or into a
// <label> around an <input>. Reasons: (1) there is no underlying <input> to
// wrap, by the choice above; (2) keeping the label outside the button means
// the button's own hit target stays exactly the track's visual bounds — a
// wrapping <label> would silently grow the clickable area to cover the text
// too, which is convenient but not what this component promises its caller.
// `aria-labelledby` still gives the switch a correct accessible name either
// way.
//
// STATE IS NEVER COLOR-ONLY (WCAG 1.4.1): the thumb's track position (left
// vs. right) is itself a shape/position difference, not a color difference —
// that alone already satisfies "not conveyed by color alone". The on/off
// color change on the track is additional, not load-bearing. The optional
// "On"/"Off" caption below is a second, textual signal, kept `aria-hidden`
// so it never gets read as a competing announcement alongside "switch,
// on/off" from `aria-checked` — one spoken source of truth, one visible
// reinforcement for sighted users who don't rely on color.

export interface SwitchProps {
  checked: boolean
  onChange: (checked: boolean) => void
  label: string
  disabled?: boolean
  // Shows a literal "On"/"Off" caption next to the track, always derived
  // from `checked` — never a second, independently-set piece of state that
  // could say something different from what aria-checked announces.
  showStateLabel?: boolean
}

export function Switch({ checked, onChange, label, disabled = false, showStateLabel = false }: SwitchProps) {
  const labelId = useId()

  return (
    <div className="inline-flex items-center gap-3">
      <button
        type="button"
        role="switch"
        aria-checked={checked}
        aria-labelledby={labelId}
        disabled={disabled}
        onClick={() => onChange(!checked)}
        className={`switch-track relative inline-flex h-6 w-11 shrink-0 items-center rounded-full border p-0.5 transition-colors duration-150 motion-reduce:transition-none disabled:cursor-not-allowed disabled:opacity-50 ${
          checked ? 'border-primary bg-primary' : 'border-outline-variant bg-surface-container-low'
        }`}
      >
        <span
          aria-hidden="true"
          className={`block h-5 w-5 rounded-full bg-background shadow transition-transform duration-150 motion-reduce:transition-none ${
            checked ? 'translate-x-5' : 'translate-x-0'
          }`}
        />
      </button>
      <span id={labelId} className="select-none text-sm text-on-surface">
        {label}
      </span>
      {showStateLabel && (
        <span aria-hidden="true" className="text-xs font-medium text-on-surface-variant">
          {checked ? 'On' : 'Off'}
        </span>
      )}
    </div>
  )
}

Accessibility pitfalls

  • Avoidrole="checkbox" (or no role at all) on what is visually a toggle switch.

    DoUse role="switch" so assistive tech announces on/off, the semantics the control actually has, not checked/unchecked.

  • Avoidaria-checked is set from a separate variable than the one driving the thumb's visual position, so the two can silently drift apart.

    DoDerive both the ARIA state and the visual position from the same single boolean — there is only ever one source of truth.

  • AvoidA hand-written onKeyDown only calls the toggle handler on Space, forgetting Enter (or the reverse).

    DoUse a native <button> — it already treats both Enter and Space as a click, so there is no custom key logic to get half-right.

  • AvoidA visible “On”/“Off” caption is set from its own state and can end up reading the opposite of what aria-checked announces.

    DoCompute the caption directly from the same checked value driving aria-checked, and mark it aria-hidden so it reinforces rather than competes with the spoken announcement.

  • AvoidThe only difference between on and off is track color, which fails for anyone who can't distinguish those colors (WCAG 1.4.1).

    DoMove the thumb to a different position (and change color as a bonus, not the only signal) so the state is visible by shape/position too.