How to Create a CSS Toggle Switch
Build an accessible CSS toggle switch with a native checkbox, visible label, keyboard support, and reduced-motion styling.
A CSS toggle switch is usually a native <input type="checkbox"> styled to look like an on/off control. Keep the checkbox as the real interactive element, give it a visible associated label, and use CSS to draw the track and thumb. That preserves form behavior and keyboard operation while making the visual state follow the actual checked state.
Use switch semantics when the setting is best understood as on or off. A checkbox can also be the right semantic choice, especially when users are selecting items in a form or the control may need a partial state. The WAI-ARIA Authoring Practices Guide explains the distinction and demonstrates a CSS-rendered switch backed by a checkbox. WAI-ARIA switch pattern · checkbox switch example.
1. Build a CSS toggle switch with HTML and CSS
Save the following as an HTML file and open it in a browser. The label text names the checkbox, the checkbox stays keyboard-focusable, and the adjacent track reflects its :checked state.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CSS toggle switch</title>
<style>
.toggle {
--track-width: 2.75rem;
--track-height: 1.5rem;
--thumb-size: 1rem;
--thumb-inset: 0.125rem;
--on-color: #146c43;
display: inline-flex;
align-items: center;
gap: 0.75rem;
position: relative;
cursor: pointer;
color: #222;
font: 1rem/1.4 system-ui, sans-serif;
}
/* Visually hide the native checkbox without removing it from keyboard access. */
.toggle__input {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.toggle__track {
position: relative;
box-sizing: border-box;
flex: none;
width: var(--track-width);
height: var(--track-height);
border: 2px solid currentColor;
border-radius: 999px;
background: Canvas;
color: #444;
transition: background-color 160ms ease;
}
.toggle__track::before {
content: "";
position: absolute;
top: 50%;
left: var(--thumb-inset);
width: var(--thumb-size);
height: var(--thumb-size);
border-radius: 50%;
background: currentColor;
transform: translateY(-50%);
transition: transform 160ms ease;
}
.toggle__input:checked + .toggle__track {
background: var(--on-color);
color: white;
}
.toggle__input:checked + .toggle__track::before {
transform: translate(calc(var(--track-width) - var(--thumb-size) - 2 * var(--thumb-inset) - 4px), -50%);
}
.toggle__input:focus-visible + .toggle__track {
outline: 3px solid #175cd3;
outline-offset: 3px;
}
.toggle__input:disabled + .toggle__track,
.toggle__input:disabled ~ .toggle__label {
opacity: 0.55;
cursor: not-allowed;
}
@media (prefers-reduced-motion: reduce) {
.toggle__track,
.toggle__track::before {
transition: none;
}
}
</style>
</head>
<body>
<form>
<label class="toggle">
<span class="toggle__label">Enable notifications</span>
<input
class="toggle__input"
type="checkbox"
name="notifications"
value="enabled"
>
<span class="toggle__track" aria-hidden="true"></span>
</label>
<button type="submit">Save</button>
</form>
</body>
</html>
For the simpler base version, the input can be followed immediately by the track, and the label can be a sibling inside the wrapping label. The :checked + .toggle__track selector relies on that adjacent-sibling order. Keep the input before the track if using this selector.
How the control works
<input type="checkbox">supplies the real binary state, keyboard interaction, and form submission behavior.- The wrapping
<label>associates its text with the checkbox. Clicking the text or track toggles the checkbox. - The track is decorative and marked
aria-hidden="true", so it does not add duplicate content to the accessibility tree. :checkedchanges the track and moves its pseudo-element thumb. There is no separate visual state to get out of sync.:focus-visibleprovides a clear keyboard focus ring, and reduced-motion preferences disable the transition.
2. Choose checkbox or switch semantics
A switch communicates that a setting is on or off, such as “Enable notifications.” A checkbox communicates checked or unchecked and fits naturally in a list of choices or a form. Both can represent a binary value, but a checkbox can also support an indeterminate or partially checked state. Choose the meaning that matches the task; do not change the label text as the state changes.
| Control meaning | Good fit | State communicated |
|---|---|---|
| Switch | An immediate preference or feature setting | On / off |
| Checkbox | A choice in a group or a form that may be submitted | Checked / unchecked; optionally mixed |
| Toggle button | An action button that remains pressed to indicate a mode | Pressed / not pressed |
The example above uses a native checkbox and does not add role="switch". That is a valid and simple checkbox implementation. If the product language specifically requires assistive technology to announce a switch, apply role="switch" to the checkbox and verify the result with your target browser and assistive technologies. The WAI-ARIA pattern says a native checkbox uses its checked state rather than a separate aria-checked value. Avoid adding redundant ARIA state.
3. Labels, keyboard use, and accessible states
- Name it visibly: use concise text that describes the setting, such as “Enable notifications.” A bare switch with no accessible name is difficult to understand.
- Keep it in the tab order: visually clip the native input instead of using
display: none,visibility: hidden, or thehiddenattribute. - Show focus: keyboard users should be able to see which switch is focused. Do not remove the outline unless you replace it with a similarly visible indicator.
- Support keyboard toggling: the native checkbox responds to Space. The label can also be activated by pointer or touch.
- Do not rely on color alone: the thumb moves between distinct positions and the track border remains visible, so state is still conveyed when color perception or forced colors change.
- Group related controls: for a set of settings, use
<fieldset>with a descriptive<legend>when that grouping makes sense. - Respect reduced motion: the media query removes animation for people who request less motion.
WAI-ARIA APG examples are implementation guidance, not a replacement for validation. The W3C example page cautions that support gaps can exist in browser and assistive technology combinations, particularly mobile and touch combinations, and recommends testing before production use. Read the W3C example notes.
4. Put switches in a form
A checkbox submits its name and value only when checked. When unchecked, that name is omitted from ordinary form data. If the server needs an explicit false value, account for the omission in your form handling or add a hidden input with a carefully chosen ordering and duplicate-value handling.
<form action="/settings" method="post">
<fieldset>
<legend>Notifications</legend>
<label class="toggle">
<span>Email alerts</span>
<input class="toggle__input" type="checkbox" name="email_alerts" value="on" checked>
<span class="toggle__track" aria-hidden="true"></span>
</label>
</fieldset>
<button type="submit">Save settings</button>
</form>
Add checked in the HTML when the initial state should be on. In a server-rendered application, render that attribute from the saved preference. In a client-rendered application, set the input’s checked property from the loaded setting before the user interacts. The control itself does not persist a preference; submit the form or handle a change event and save the value through your application.
5. Customize dimensions and colors
The sample uses CSS custom properties for dimensions and the on color. Change these values together so the thumb still fits inside the track. The checked thumb offset is calculated from the track width, thumb size, inset, and border width.
| Property | What to change | Check afterward |
|---|---|---|
--track-width, --track-height |
Overall switch size | Enough room remains for the thumb and both end gaps |
--thumb-size |
Thumb diameter | Thumb remains inside the track in both states |
--thumb-inset |
Gap between thumb and track edge | Checked translation still lands at the far end |
--on-color |
On-state fill | Track and thumb contrast remain clear |
The example uses the system color Canvas for the off background so it can adapt to system presentation. If you choose fixed colors, define both state colors and test text, border, and thumb contrast in light mode, dark mode, and forced-colors or high-contrast settings. Native form controls can inherit platform styling; if you instead reset a checkbox with appearance: none, write explicit rules for both checked and unchecked states, focus, disabled appearance, and high contrast. MDN documents appearance as a way to alter native widget rendering while retaining the widget’s functionality. MDN: appearance.
6. Common problems and fixes
| Problem | Likely cause | Fix |
|---|---|---|
| The thumb does not move when checked | The CSS selector expects the track immediately after the checkbox, but another element sits between them or the class differs. | Keep .toggle__input directly before .toggle__track, or update the selector to match the actual DOM. |
| The switch is invisible to keyboard users | The input was removed from focus with display: none or visibility: hidden. |
Use the visually clipped input pattern and check that Tab reaches it. |
| There is no visible focus indicator | Browser outline styles were reset, or the focus selector does not match the track. | Restore a clear :focus-visible outline and verify the checkbox-to-track selector. |
| Clicking the text does not toggle the input | The label is not wrapping the input and text, and its for value may not match the input id. |
Wrap both in one label, as in the sample, or use matching for and id attributes. |
| The form does not send an off value | Unchecked checkboxes are omitted from standard form submission. | Interpret a missing field as false on the server, or implement explicit false submission while accounting for duplicate names. |
| The state is hard to distinguish in high contrast | The design relies only on a color change. | Keep a border and move the thumb so position also distinguishes on from off; test forced-colors presentation. |
| The animation feels distracting | The transition runs for every user. | Keep the prefers-reduced-motion rule and consider removing motion altogether. |
Adding role="switch" causes confusing output |
The role is mismatched with the control’s meaning, or redundant ARIA state was added. | Choose semantics that match the task; for a native checkbox, let the checked property convey state and test the role only when switch announcement is needed. |
7. Test before shipping
- Click the label and track with a pointer; confirm each toggles the same input.
- Use Tab and Shift+Tab to reach the control; confirm the focus indicator is visible.
- Press Space to toggle and confirm the visible thumb state changes.
- Submit the form in both states; verify the server interprets a missing unchecked field correctly.
- Inspect the accessible name and announced state using the screen reader and browsers your product supports.
- Check at narrow widths, browser zoom, dark mode, and forced-colors/high-contrast settings.
- Turn on the operating system’s reduced-motion preference and verify the switch no longer animates.
Testing matters because CSS correctness alone does not guarantee that every browser and assistive technology combination exposes the interaction in the same way. Use the W3C pattern as a guide and validate the finished control in the environments your users rely on.
8. Or skip the browser setup
If what you need is a screenshot of a page showing your switch, an API can capture it without you setting up a browser. ScreenshotNeo’s API documentation covers its screenshot options.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
timeout=90,
)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await Bun.write('shot.webp', res);
Replace https://stripe.com with a URL for your page and YOUR_API_KEY with your key. ScreenshotNeo removes cookie banners, newsletter popups, and chat widgets before capture. Bot checks, blank pages, and failed loads are never billed. Its MCP server lets AI agents take screenshots. The free plan includes 1,000 screenshots each month with no card; paid plans start at $5 for 3,000. Learn about ScreenshotNeo, then sign up for 1,000 free screenshots a month with no card.
9. FAQ
Can CSS alone make the switch remember its value?
No. CSS styles the current state; the checkbox stores that state only while the page is active. Save the preference through your form or application code if it must persist.
Does an unchecked switch submit false?
Not by default. An unchecked checkbox is omitted from standard form data, so interpret a missing field as false or handle explicit false values in your form design.
Do I need JavaScript for this toggle?
No, not for the visual toggle or native interaction. JavaScript is only needed for application behavior such as immediately saving the preference without submitting a form.
Should I use appearance: none?
It is an option when styling the input itself, but the separate track approach keeps a native input underneath and avoids depending on a fully custom checkbox rendering. If you use appearance: none, define and test focus and state styles explicitly.


