A toggle switch is a two-state control that exposes one boolean as both visible and accessible state. Build it as a React component whose track, knob, status text, and aria-checked value all derive from the same state value.
Implement the default App component in App.tsx. It receives no props and owns an on boolean with useState(false).
Off, and the button exposes aria-checked="false".On, and exposes aria-checked="true".on; do not store them separately.role="switch" on the <button> so click, Space, Enter, and focus behavior are built in.Notifications label with aria-labelledby.on class changes the track color and knob transform.You'll hold one boolean in useState and let it drive the class, the aria-checked value, and the label together.
A switch is a single boolean. The mistake is to treat "the knob position", "the green track", "the aria state", and "the On/Off text" as four things to keep in sync. They're one thing — on — rendered four ways. Flip the boolean and React re-renders everything derived from it.
A common first try reaches for the DOM to flip the class directly:
export default function App() {
function handleClick(e) {
e.currentTarget.classList.toggle('on'); // imperative DOM poke
}
return <button className="switch" onClick={handleClick}><span className="knob" /></button>;
}
It even looks right — the knob slides. But there's no state, so aria-checked stays false forever (screen readers now lie), the status text can't update, and any other part of the component that needs to know "is it on?" has no way to ask. In React the class should be a consequence of state, not the state itself.
import { useState } from 'react';
import './styles.css';
export default function App() {
const [on, setOn] = useState(false);
return (
<main className="container">
<h1>Toggle Switch</h1>
<div className="row">
<span id="notifications-label" className="label">Notifications</span>
<button
type="button"
className={on ? 'switch on' : 'switch'}
role="switch"
aria-checked={on}
aria-labelledby="notifications-label"
onClick={() => setOn((v) => !v)}
>
<span className="knob" />
</button>
</div>
<p className="status" aria-live="polite">{on ? 'On' : 'Off'}</p>
</main>
);
}
on is the single source of truth. The onClick flips it with a functional update (v => !v, robust even if clicks batch). Everything visible is derived: className gets on (CSS slides the knob and greens the track), aria-checked={on} keeps assistive tech honest, and the status text reads the same boolean. No DOM poking, no duplicated state.
on = false → className="switch", aria-checked={false}, status "Off". Knob left, track grey.setOn(v => !v) → on = true → re-render → className="switch on" (knob slides right, track green), aria-checked={true}, status "On".false. Each click toggles the one boolean.classList.toggle desyncs aria-checked and the label. Drive the class from state instead.aria-checked="true" as a string — pass the boolean (aria-checked={on}); React serialises it correctly, and a stale string breaks the switch semantics.<div> switch — a plain div isn't focusable or announced. Use a <button role="switch"> so keyboard and screen-reader support come for free.checked + onChange props so a parent can own the state (the <input type="checkbox"> pattern).<button> already toggles on Space/Enter; add Left/Right handling to match the full APG switch pattern.disabled prop and native disabled attribute when the setting cannot be changed.This version uses a reducer for the repeated state transition. The class, switch semantics, and visible status all remain derived from the returned boolean.
import { useReducer } from 'react';
import './styles.css';
export default function App() {
const [on, toggle] = useReducer((value: boolean) => !value, false);
return (
<main className="container">
<h1>Toggle Switch</h1>
<div className="row">
<span id="notifications-label" className="label">Notifications</span>
<button
type="button"
className={on ? 'switch on' : 'switch'}
role="switch"
aria-checked={on}
aria-labelledby="notifications-label"
onClick={toggle}
>
<span className="knob" />
</button>
</div>
<p className="status" aria-live="polite">{on ? 'On' : 'Off'}</p>
</main>
);
}Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
A toggle switch is a two-state control that exposes one boolean as both visible and accessible state. Build it as a React component whose track, knob, status text, and aria-checked value all derive from the same state value.
Implement the default App component in App.tsx. It receives no props and owns an on boolean with useState(false).
Off, and the button exposes aria-checked="false".On, and exposes aria-checked="true".on; do not store them separately.role="switch" on the <button> so click, Space, Enter, and focus behavior are built in.Notifications label with aria-labelledby.on class changes the track color and knob transform.You'll hold one boolean in useState and let it drive the class, the aria-checked value, and the label together.
A switch is a single boolean. The mistake is to treat "the knob position", "the green track", "the aria state", and "the On/Off text" as four things to keep in sync. They're one thing — on — rendered four ways. Flip the boolean and React re-renders everything derived from it.
A common first try reaches for the DOM to flip the class directly:
export default function App() {
function handleClick(e) {
e.currentTarget.classList.toggle('on'); // imperative DOM poke
}
return <button className="switch" onClick={handleClick}><span className="knob" /></button>;
}
It even looks right — the knob slides. But there's no state, so aria-checked stays false forever (screen readers now lie), the status text can't update, and any other part of the component that needs to know "is it on?" has no way to ask. In React the class should be a consequence of state, not the state itself.
import { useState } from 'react';
import './styles.css';
export default function App() {
const [on, setOn] = useState(false);
return (
<main className="container">
<h1>Toggle Switch</h1>
<div className="row">
<span id="notifications-label" className="label">Notifications</span>
<button
type="button"
className={on ? 'switch on' : 'switch'}
role="switch"
aria-checked={on}
aria-labelledby="notifications-label"
onClick={() => setOn((v) => !v)}
>
<span className="knob" />
</button>
</div>
<p className="status" aria-live="polite">{on ? 'On' : 'Off'}</p>
</main>
);
}
on is the single source of truth. The onClick flips it with a functional update (v => !v, robust even if clicks batch). Everything visible is derived: className gets on (CSS slides the knob and greens the track), aria-checked={on} keeps assistive tech honest, and the status text reads the same boolean. No DOM poking, no duplicated state.
on = false → className="switch", aria-checked={false}, status "Off". Knob left, track grey.setOn(v => !v) → on = true → re-render → className="switch on" (knob slides right, track green), aria-checked={true}, status "On".false. Each click toggles the one boolean.classList.toggle desyncs aria-checked and the label. Drive the class from state instead.aria-checked="true" as a string — pass the boolean (aria-checked={on}); React serialises it correctly, and a stale string breaks the switch semantics.<div> switch — a plain div isn't focusable or announced. Use a <button role="switch"> so keyboard and screen-reader support come for free.checked + onChange props so a parent can own the state (the <input type="checkbox"> pattern).<button> already toggles on Space/Enter; add Left/Right handling to match the full APG switch pattern.disabled prop and native disabled attribute when the setting cannot be changed.This version uses a reducer for the repeated state transition. The class, switch semantics, and visible status all remain derived from the returned boolean.
import { useReducer } from 'react';
import './styles.css';
export default function App() {
const [on, toggle] = useReducer((value: boolean) => !value, false);
return (
<main className="container">
<h1>Toggle Switch</h1>
<div className="row">
<span id="notifications-label" className="label">Notifications</span>
<button
type="button"
className={on ? 'switch on' : 'switch'}
role="switch"
aria-checked={on}
aria-labelledby="notifications-label"
onClick={toggle}
>
<span className="knob" />
</button>
</div>
<p className="status" aria-live="polite">{on ? 'On' : 'Off'}</p>
</main>
);
}Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.