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.2A1.3.1AEN 301 549Section 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
Use a native <button type="button"> for the toggle, not a
<div> or a link.
Add aria-pressed="false" and set it to "true" when
the control is on; update it every time the state flips.
Keep the visible label constant and let aria-pressed express the
state, so the name and the announced state never disagree.
Don’t signal on/off with colour alone — keep a text label or icon so 1.4.1 is
met as well.
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.2A2.1.1AEN 301 549Section 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.
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.
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
Start from a native <input type="checkbox"> with a real
<label>; style that to look like a switch.
Never build an interactive control from a bare <div> or
<span> — it has no role, name, state, or focusability.
Add role="switch" only when you need it announced as “on/off”
rather than “checked/unchecked”; keep aria-checked in sync.
For a fully custom switch, make it tabindex="0" and handle Enter
and Space so it’s keyboard-operable (2.1.1).
Give it an accessible name via the <label>,
aria-label, or aria-labelledby.
State change not announced
WCAG 2.2 · 4.1.2A4.1.3AAEN 301 549ADA 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.
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.
On every change, update the control’s own aria-pressed or
aria-checked — don’t change only the CSS class.
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.
Add a polite live region (role="status" /
aria-live="polite") only when flipping the toggle affects content
elsewhere on the page (4.1.3).
Don’t double up — a control that announces its own state plus a redundant live
region makes users hear the change twice.
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.