// 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.
// 01 · live demo
Live Demo
Use Tab to move between switches. Press Space or Enter to toggle.
Inaccessible version (div-based):
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
<!-- 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>
.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;
}
}
// 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));
});
});
<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) |
<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.5remandmin-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. Therole="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
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.
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.
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.
<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.
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 |
<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.