// pattern

Accessible Switch / Toggle Pattern

A toggle switch built with <button> and role="switch", providing immediate on/off state changes with proper keyboard support, visible labels, and screen reader announcements via aria-checked.

aria-required intermediate wcag-2.2

// 01 · live demo

Live Demo

Use Tab to move between switches. Press Space or Enter to toggle.

Email notifications
Dark mode
Auto-save

Inaccessible version (div-based):

Email notifications
Dark mode

This version uses plain <div> elements. No keyboard support, no role, no aria-checked state, no label association. Click works, but nothing else does.

// 02 · the code

The Code

HTML
<!-- Switch with visible label -->
<div class="switch-group">
  <span class="switch-label" id="label-email">
    Email notifications
  </span>
  <button role="switch"
          aria-checked="false"
          aria-labelledby="label-email">
  </button>
</div>

<!-- Switch that is on by default -->
<div class="switch-group">
  <span class="switch-label" id="label-autosave">
    Auto-save
  </span>
  <button role="switch"
          aria-checked="true"
          aria-labelledby="label-autosave">
  </button>
</div>
CSS
.switch-group {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
}

.switch-label {
  font-size: 0.875rem;
  font-weight: 600;
}

/* Track */
[role="switch"] {
  position: relative;
  width: 2.75rem;
  height: 1.5rem;
  padding: 0;
  border: 2px solid #d1d5db;
  border-radius: 0.75rem;
  background: #d1d5db;
  cursor: pointer;
  flex-shrink: 0;
  /* SC 2.5.8: minimum 24x24 target */
  min-width: 2.75rem;
  min-height: 1.5rem;
  transition: background-color 0.15s ease,
              border-color 0.15s ease;
}

/* Knob */
[role="switch"]::after {
  content: "";
  position: absolute;
  top: 2px;
  left: 2px;
  width: 1rem;
  height: 1rem;
  border-radius: 50%;
  background: #fff;
  transition: transform 0.15s ease;
}

/* Checked state — color + knob position */
[role="switch"][aria-checked="true"] {
  background: #5b2a86;
  border-color: #5b2a86;
}

[role="switch"][aria-checked="true"]::after {
  transform: translateX(1.25rem);
}

/* SC 2.4.13: focus indicator with 3:1 contrast */
[role="switch"]:focus-visible {
  outline: 2px solid #5b2a86;
  outline-offset: 2px;
  border-radius: 0.75rem;
}

/* Respect reduced motion */
@media (prefers-reduced-motion: reduce) {
  [role="switch"],
  [role="switch"]::after {
    transition: none;
  }
}
JavaScript
// Toggle aria-checked on click
document.querySelectorAll('[role="switch"]').forEach(sw => {
  sw.addEventListener('click', () => {
    const checked = sw.getAttribute('aria-checked') === 'true';
    sw.setAttribute('aria-checked', String(!checked));
  });
});
Why so little JavaScript? Because the switch uses a native <button>, keyboard activation (Enter and Space) fires the click event automatically. The only JavaScript needed is toggling the aria-checked attribute. No keydown handlers, no focus management, no tabindex manipulation.

// 03 · why these decisions

Why These Decisions

Why role="switch" instead of a checkbox

A switch implies an immediate action — toggling it takes effect right away, like flipping a light switch. A checkbox implies a deferred action — the user checks options and then submits a form. When the toggle controls a live setting (notifications, dark mode, auto-save), role="switch" communicates the correct interaction model to assistive technology users. Screen readers announce it as "switch" rather than "checkbox," setting the right expectation.

Why <button> as the base element

A native <button> element is focusable by default and responds to both Enter and Space keypresses by firing a click event. Using a <div> or <span> would require adding tabindex="0", a keydown handler for Space and Enter, and the role="switch" on top. The button gives you keyboard operability and focus management for free.

Why aria-checked instead of aria-pressed

The role="switch" specification requires aria-checked, not aria-pressed. While aria-pressed is the correct state for toggle buttons (role="button"), switches use aria-checked because they model an on/off binary state. Using aria-pressed on a switch is invalid ARIA and may cause unpredictable screen reader behavior.

Why a visible text label

Every switch needs a label that sighted users can read. Using only aria-label makes the switch accessible to screen reader users but leaves sighted users guessing what the toggle controls. A visible <span> with an id connected via aria-labelledby serves both audiences. The label is visible and programmatically associated.

Why CSS transitions with reduced motion respect

The sliding knob animation communicates the state change visually — the knob moves from left to right when toggled on. This motion reinforces the state indicator (in addition to color and position). However, some users experience discomfort from motion, so the prefers-reduced-motion media query disables the transition for those users. The state change still works — the knob simply appears in its new position instantly.

// 04 · keyboard interaction

Keyboard Interaction

Key Action
Tab Moves focus to the next switch in the tab order
Space Toggles the switch between on and off
Enter Toggles the switch (native <button> behavior — no extra JS needed)
Minimal keyboard handling Because the switch is built on a native <button>, both Space and Enter fire the click event automatically. No arrow key handling is needed — unlike tabs, switches are independent controls that each live in the normal tab order.

// 05 · wcag 2.2 success criteria

WCAG 2.2 Success Criteria

This pattern satisfies the following WCAG 2.2 success criteria:

  • 4.1.2 Name, Role, Value Level A — The button exposes its name (from aria-labelledby), role (switch), and state (aria-checked) to the accessibility tree.
  • 2.1.1 Keyboard Level A — The switch is fully operable via keyboard. Tab moves focus to it, Space and Enter toggle the state.
  • 1.3.1 Info and Relationships Level A — The visible label is programmatically associated with the switch via aria-labelledby, conveying the relationship to assistive technology.
  • 1.4.1 Use of Color Level A — State is not conveyed by color alone. The knob position (left for off, right for on) provides a non-color indicator of the current state.
  • 2.4.13 Focus Appearance Level AAA — The switch has a visible 2px solid outline focus indicator with sufficient contrast when focused via keyboard.
  • 2.5.8 Target Size (Minimum) Level AA — The switch meets the 24x24px minimum target size via min-height: 1.5rem and min-width: 2.75rem.
  • 4.1.3 Status Messages Level AA — When the switch is toggled, the state change (aria-checked) is announced by screen readers without requiring focus to move. The role="switch" ensures the new state is communicated automatically.

// 06 · screen reader behavior

Screen Reader Behavior

When navigating to a switch

  • NVDA: "Email notifications, switch, on" — announces the label, role, and current state
  • JAWS: "Email notifications switch on" — announces label, role, and state
  • VoiceOver: "Email notifications, on, switch" — announces label, state, then role

After toggling the switch

  • NVDA: "off" — announces only the new state, since the user already knows the label and role
  • JAWS: "off" — announces the new state
  • VoiceOver: "off, switch" — announces the new state and confirms the role

Why "on/off" instead of "checked/unchecked"

Screen readers use "on" and "off" for role="switch" elements, compared to "checked" and "not checked" for checkboxes. This distinction helps users understand the interaction model — a switch takes immediate effect, while a checkbox selection is typically submitted later. The different vocabulary reinforces the different behavior.

// 07 · common mistakes

Common Mistakes

Using a checkbox styled as a switch without role="switch" A styled checkbox still announces as "checkbox" to screen readers, which implies deferred action (submit later). If the toggle takes immediate effect, users hear the wrong interaction model. Add role="switch" or use a button with the switch role to communicate the correct semantics.
Using aria-pressed instead of aria-checked The role="switch" specification requires aria-checked. Using aria-pressed is invalid for this role and may cause screen readers to ignore the state entirely or announce it incorrectly. Always pair role="switch" with aria-checked.
No visible label Using only aria-label provides an accessible name for screen reader users but leaves sighted users without context. A switch sitting alone with no visible text next to it forces sighted users to guess what it controls. Always include a visible text label.
Color as the only state indicator If the switch changes from gray to blue but the knob does not move, colorblind users cannot distinguish the states. The knob position (left vs. right) must change to provide a non-color state indicator. Shape or position changes are required alongside color changes.
Using a <div> or <span> instead of <button> A <div> is not focusable or keyboard-activatable by default. You would need to add tabindex="0", a keydown listener for Space and Enter, and the appropriate ARIA role — all of which a <button> provides for free. Use the native element.
Not announcing the state change If you toggle a CSS class instead of updating aria-checked, screen readers have no way to know the state changed. The aria-checked attribute is what triggers the state announcement. Always update the attribute, not just the visual appearance.

// 08 · native html vs. aria

Native HTML vs. ARIA

HTML has no native switch element. A <button> provides the keyboard foundation, but ARIA is required to communicate the switch semantics and state.

Feature Native HTML contribution ARIA requirement
Trigger element <button> — focusable, keyboard-activatable role="switch" — overrides button semantics with switch semantics
Toggle state None — buttons have no native on/off state aria-checked="true/false" — required to convey current state
State announcement None Handled automatically by role="switch" + aria-checked — screen readers say "on" or "off"
Label Visible <span> text provides the visual label aria-labelledby connects the label to the switch programmatically. aria-label works but hides the label from sighted users.
Keyboard activation <button> fires click on Enter and Space None — native button handles this entirely
Focus management <button> is in the tab order by default None — no special focus management required
Visual state CSS [aria-checked="true"] selector styles the checked state The ARIA attribute doubles as a CSS hook — no extra classes needed
Motion preferences prefers-reduced-motion media query disables transitions None — this is a CSS/browser feature
Click handling <button> click event fires on mouse and keyboard JavaScript toggles aria-checked — the only required script
The verdict The switch pattern is a partnership between native HTML and ARIA. The <button> does all the heavy lifting for keyboard interaction and focus, while role="switch" and aria-checked provide the semantics that HTML lacks. The result is minimal JavaScript — just one line to toggle the attribute — because native HTML handles everything else.