Toggle buttons & switches

A toggle is a control with two sticky states: it stays on until you turn it off. That makes it different from an ordinary button, which fires an action and returns to rest. Mute, “follow”, dark mode, Wi-Fi, “remember me” — all of these are toggles, and the whole point of a toggle is its state. If the on/off state isn’t exposed to assistive technology, a screen reader user can press the control but never learn whether it did anything, or what position it was already in.

There are two correct shapes. A toggle button is a real <button> that carries aria-pressed and is announced as “pressed” or “not pressed”. A switch is either a native checkbox or an element with role="switch" and aria-checked, announced as “on” or “off”. This lesson works through the three defects that break them most often — and every fix uses a real, focusable control with a state attribute, not a styled <div>.

What you’ll learn

How to expose the on/off state of a toggle button with aria-pressed; how to build a switch from a native checkbox or role="switch" with aria-checked instead of a bare <div>; and how to make sure a state change is announced — by updating the control’s own state, not by adding a noisy second message — so assistive technology always reports the current position.

Standards this lesson maps to
Standard Criterion Level What it requires
WCAG 2.2 4.1.2 Name, Role, Value A A toggle exposes its name, a button or switch role, and its current state (pressed/checked) to assistive technology.
WCAG 2.2 1.3.1 Info and Relationships A The on/off state is conveyed in the markup, not by colour or position alone.
WCAG 2.2 4.1.3 Status Messages AA When a toggle’s state changes without moving focus, the change is exposed to assistive tech.
WCAG 2.2 2.1.1 Keyboard A The toggle is operable with the keyboard — focusable and flipped with Enter or Space.
WCAG 2.2 1.4.1 Use of Color A On/off is not signalled by colour alone; a text or shape cue is also present.
EN 301 549 9.4.1.2 (incorporates WCAG) European harmonised standard; references the WCAG A/AA set including name, role, value.
Section 508 502.3 / 504 (incorporates WCAG A & AA) US federal ICT must meet WCAG 2.0 Level A and AA, including control state.
ADA Title II WCAG 2.1 AA (DOJ rule) AA US state/local government web content must conform to WCAG 2.1 AA.

The three problems we’ll fix

Each card below isolates one common toggle defect. For every issue you get a plain-language statement of the problem, a Bad example (shown as escaped, non-running code so it can’t harm this page), a Good example, the copyable Code, and an ordered fix.

On/off button with no pressed state

WCAG 2.2 · 4.1.2 A 1.3.1 A EN 301 549 Section 508

A button that turns something on and off — Mute, Follow, Bold — only looks toggled. The “on” look is carried by a CSS class or a colour swap, which the accessibility tree never sees. A screen reader announces it as a plain “Mute, button” in both states, so the user can’t tell whether mute is currently active before they press it, and gets no confirmation after. The fix is one attribute: a real <button> with aria-pressed="true" or "false", which makes it a toggle button announced as “Mute, pressed” or “Mute, not pressed”. Keep the visible label stable (“Mute”), and let aria-pressed carry the state — don’t swap the text between “Mute” and “Unmute”, or the name and state will say different things.

Bad

State lives only in a CSS class. The button is announced identically whether mute is on or off, so its state is invisible to assistive technology (4.1.2).

bad-toggle-button.html
<!-- "Active" state is only a class; AT sees no change -->
<button type="button" class="toggle is-active">Mute</button>

Good

A native <button> carries aria-pressed. The label stays “Mute”; the attribute carries the state, so it is announced as “Mute, pressed” when active.

good-toggle-button.html
<button type="button" aria-pressed="false">Mute</button>

<!-- when active -->
<button type="button" aria-pressed="true">Mute</button>

Code

Flip the attribute on click. Because it’s a real button it’s already focusable and operable with Enter and Space (2.1.1) — you only have to keep aria-pressed in sync with the visual state.

toggle-button.js
const btn = document.querySelector('.mute');
btn.addEventListener('click', () => {
  const on = btn.getAttribute('aria-pressed') === 'true';
  btn.setAttribute('aria-pressed', String(!on));
  // toggle the visual class too — state and style stay in sync
  btn.classList.toggle('is-active', !on);
});

How to fix

  1. Use a native <button type="button"> for the toggle, not a <div> or a link.
  2. Add aria-pressed="false" and set it to "true" when the control is on; update it every time the state flips.
  3. Keep the visible label constant and let aria-pressed express the state, so the name and the announced state never disagree.
  4. Don’t signal on/off with colour alone — keep a text label or icon so 1.4.1 is met as well.
  5. Verify in the accessibility tree that it reads “Mute, toggle button, pressed” when active.

Switch built from a <div> instead of a real control

WCAG 2.2 · 4.1.2 A 2.1.1 A EN 301 549 Section 508

The iOS-style sliding switch is the most cloned widget on the web, and it’s usually a <div> with a click handler. A bare <div> has no role, no accessible name, no state, and no place in the tab order, so a screen reader skips past it and a keyboard user can never reach it (4.1.2, 2.1.1). The robust fix is a native <input type="checkbox"> with a real <label>, styled to look like a switch — it’s focusable, keyboard-operable, and announced as checked/unchecked for free. When the on/off (rather than checked) semantics matter, add role="switch" so it is announced as “on/off”; otherwise the plain checkbox is enough.

Bad

A <div> with a click handler. No role, no name, no state, and it can’t be reached or operated with the keyboard.

bad-switch.html
<div class="switch" onclick="toggle(this)">
  <div class="switch__knob"></div>
</div>

Good

A native checkbox with a real label, styled as a switch. It is focusable, toggles with Space, and is announced with its name and checked state — no ARIA required.

good-switch-checkbox.html
<label class="switch">
  <input type="checkbox" name="wifi" checked>
  <span class="switch__track"></span>
  Wi-Fi
</label>

Code

If the design needs explicit “on/off” semantics, add role="switch" to the checkbox, or build one on a <button> with aria-checked. A custom switch must itself be focusable (tabindex="0") and toggle on Enter and Space.

role-switch.html
<!-- Native checkbox given switch semantics -->
<label><input type="checkbox" role="switch" checked> Wi-Fi</label>

<!-- Or a custom switch on a real button -->
<button type="button" role="switch" aria-checked="true">
  <span>Wi-Fi</span>
</button>

How to fix

  1. Start from a native <input type="checkbox"> with a real <label>; style that to look like a switch.
  2. Never build an interactive control from a bare <div> or <span> — it has no role, name, state, or focusability.
  3. Add role="switch" only when you need it announced as “on/off” rather than “checked/unchecked”; keep aria-checked in sync.
  4. For a fully custom switch, make it tabindex="0" and handle Enter and Space so it’s keyboard-operable (2.1.1).
  5. Give it an accessible name via the <label>, aria-label, or aria-labelledby.

State change not announced

WCAG 2.2 · 4.1.2 A 4.1.3 AA EN 301 549 ADA Title II

A toggle can have the right role and a starting state yet still fall silent when it’s used, because the script changes the visual but never updates aria-pressed or aria-checked. The control is focused, so nothing re-reads it; the user flips it and hears no confirmation, or hears the old state. The reliable fix is to update the control’s own state attribute on every change: when the focused element’s aria-pressed or aria-checked changes, assistive technology re-announces the new state automatically (4.1.2). Only when a toggle has a side effect somewhere else on the page — “Saved”, “Dark mode on” — do you add a polite live region (4.1.3); don’t bolt a redundant live region onto a control that already announces itself, or users hear the change twice.

Bad

The click handler toggles a class but leaves aria-pressed untouched, so the announced state never changes and quickly goes stale.

bad-stale-state.js
<button type="button" aria-pressed="false" class="follow">Follow</button>
<script>
  follow.addEventListener('click', () => {
    follow.classList.toggle('is-on'); // visual only
    // aria-pressed never updated → AT still says "not pressed"
  });
</script>

Good

The handler updates aria-pressed itself. Because the toggle is focused, the screen reader re-announces “pressed” / “not pressed” on each change — no separate message needed.

good-sync-state.js
follow.addEventListener('click', () => {
  const on = follow.getAttribute('aria-pressed') === 'true';
  follow.setAttribute('aria-pressed', String(!on));
  follow.classList.toggle('is-on', !on);
});

Code

When flipping the toggle changes something elsewhere, announce that effect in a polite live region that already exists in the DOM. Keep the text short, and let the live region — not the toggle — carry the side-effect message.

live-region.html
<button type="button" role="switch" aria-checked="false" id="dark">Dark mode</button>
<p id="theme-status" role="status" aria-live="polite"></p>
<script>
  dark.addEventListener('click', () => {
    const on = dark.getAttribute('aria-checked') !== 'true';
    dark.setAttribute('aria-checked', String(on));
    document.documentElement.classList.toggle('theme-dark', on);
    themeStatus.textContent = on ? 'Dark mode on' : 'Dark mode off';
  });
</script>

How to fix

  1. On every change, update the control’s own aria-pressed or aria-checked — don’t change only the CSS class.
  2. Trust the role: a focused toggle whose state attribute changes is re-announced by assistive tech automatically (4.1.2), so usually no extra message is needed.
  3. Add a polite live region (role="status" / aria-live="polite") only when flipping the toggle affects content elsewhere on the page (4.1.3).
  4. Don’t double up — a control that announces its own state plus a redundant live region makes users hear the change twice.
  5. Test with a screen reader: toggle the control and confirm the new state is spoken exactly once.

Recap

  • A toggle button is a real <button> with aria-pressed kept in sync with its state, so it is announced as “pressed” or “not pressed” (4.1.2, 1.3.1).
  • A switch is a native checkbox or an element with role="switch" and aria-checked — never a styled <div> that lacks a role, a name, and keyboard support (4.1.2, 2.1.1).
  • When the state flips, update the control’s own aria-pressed or aria-checked attribute; assistive tech re-announces the new state on its own, so you rarely need a separate live region (4.1.3).
  • Don’t rely on colour or knob position alone to show on/off — pair it with a label or text cue (1.4.1).

The same fixes satisfy WCAG, EN 301 549, Section 508, and ADA Title II at once — expose name, role, and state correctly and you meet them all.