9 CSS Toggle Switches — One Line, Copy-Paste
A css toggle switch is a real <input type="checkbox"> styled as a track and knob, so :checked decides which side the knob sits on, not a class a
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Basic slide toggle
- 02 iOS-style toggle
- 03 ON/OFF label inside
- 04 Icon swap toggle
- 05 Squish press effect
- 06 Color-fill background
- 07 Day/night icon toggle
- 08 Size variant mixin
- 09 Disabled state style
The order below tracks how much of each switch actually changes once the box gets checked. 01 and 02 only move a knob and cross-fade two background layers through opacity, one on a plain transition and one on a spring easing. 03 and 04 give the knob something to travel past — fixed ON/OFF text or a cross-fading icon — instead of an empty track. 05 layers a press on top of that same slide, driven by :active rather than :checked. 06 and 07 let the track's own background do the moving, through clip-path and background-position, and 08 computes three sizes from one SCSS mixin instead of tuning each by hand. 09 closes the set by never really toggling at all, since it starts disabled and stays that way.
01Basic slide toggle
Checking the box slides the knob to translateX(24px) while two stacked background layers cross-fade through opacity, so the track's color swap never touches background-color directly. It's the plain shape for a settings row or any checkbox a form wants to present as a switch instead.
.switch__bg--off { background: rgba(255, 255, 255, .18); opacity: 1; }
.switch__bg--on { background: $color; opacity: 0; }
.switch__knob {
transform: translateX(0);
transition: transform $duration $easing;
}
.switch__input:checked ~ .switch__track .switch__bg--off { opacity: 0; }
.switch__input:checked ~ .switch__track .switch__bg--on { opacity: 1; }
.switch__input:checked ~ .switch__track .switch__knob { transform: translateX(24px); }
02iOS-style toggle
The knob's transition runs on a spring curve, cubic-bezier(.2, .8, .2, 1), so a real click eases it straight to 24px with no overshoot. The idle preview above exaggerates that same spring, bouncing the knob out to 28px before it settles back for visual effect. It fits a mobile settings screen where a switch already carries the tactile feel iOS trained people to expect.
$easing: cubic-bezier(.2, .8, .2, 1);
.switch__knob {
transform: translateX(0);
transition: transform $duration $easing;
}
.switch__input:checked ~ .switch__track .switch__knob {
transform: translateX(24px);
}
03ON/OFF label inside
ON and OFF text sits fixed at both ends of the track from the start. The knob — raised to z-index: 1 — slides on top of whichever letters share its current position, covering whichever one it reaches without either label ever moving on its own. Admin dashboards reach for it when a switch's current value has to read as text, not just a knob's side.
.switch__text--on { left: 10px; }
.switch__text--off { right: 10px; }
.switch__knob {
position: absolute; top: 3px; left: 3px; z-index: 1;
transition: transform $duration $easing;
}
.switch__input:checked ~ .switch__track .switch__knob {
transform: translateX(44px);
}
04Icon swap toggle
While the knob slides 26px, an X icon inside it fades to opacity: 0 as a check icon fades in to take its place, both cross-fading on the same transition that drives the slide. Permission and notification lists use it since the outcome reads from the icon alone, no label required.
.switch__knob { transform: translateX(0); transition: transform $duration $easing; }
.switch__icon--x { opacity: 1; }
.switch__icon--check { opacity: 0; }
.switch__input:checked ~ .switch__track .switch__knob { transform: translateX(26px); }
.switch__input:checked ~ .switch__track .switch__icon--x { opacity: 0; }
.switch__input:checked ~ .switch__track .switch__icon--check { opacity: 1; }
05Squish press effect
Position and press live in separate custom properties — --x for where :checked parks the knob, --sx/--sy for how far :active squishes it. Pressing stretches the knob to scaleX(1.35) without disturbing wherever --x already left it. Touch-first forms use the combination so a press reads as a squeeze before the switch has even finished sliding.
.switch__knob {
--x: 0px; --sx: 1; --sy: 1;
transform: translateX(var(--x)) scaleX(var(--sx)) scaleY(var(--sy));
transition: transform $duration $easing;
}
.switch__input:checked ~ .switch__track .switch__knob { --x: 24px; }
.switch:active .switch__knob { --sx: 1.35; --sy: .8; }
06Color-fill background
Checking the box doesn't recolor the track directly. A separate fill layer's clip-path grows from a 0% circle pinned at the track's left edge out to a 75% circle anchored on the right, so the color visibly spreads from where the knob is heading. Premium settings cards and plan-upgrade toggles use that spread to make the switch itself worth watching.
.switch__fill {
background: $color;
clip-path: circle(0% at 18% 50%);
transition: clip-path $duration $easing;
}
.switch__input:checked ~ .switch__track .switch__fill {
clip-path: circle(75% at 82% 50%);
}
07Day/night icon toggle
The track's own background is a two-color gradient sized at 200% width, so sliding its background-position from 0% to 100% swaps which half shows without the gradient ever being redefined. Three star dots fade in at the same time, and the knob's sun icon cross-fades to a moon. Theme switches get more than an icon flip this way, since the whole sky is the control now.
.switch__track {
background-image: linear-gradient(90deg, $stage-yellow 0%, $stage-yellow 50%, $color 50%, $color 100%);
background-size: 200% 100%;
background-position: 0% 0%;
transition: background-position $duration $easing;
}
.switch__input:checked ~ .switch__track { background-position: 100% 0%; }
.switch__input:checked ~ .switch__track .switch__star { opacity: 1; }
08Size variant mixin
One @mixin takes a track width, height, and knob size, and derives a single --travel custom property from them. Small, medium, and large switches all read transform: translateX(var(--travel)) instead of three separately tuned distances. Design-system components use it so three sizes stay one switch, not three that happen to look similar.
@mixin switch-size($track-w, $track-h, $knob) {
--travel: #{$track-w - $knob - 6px};
.switch__track { width: $track-w; height: $track-h; }
.switch__knob { width: $knob; height: $knob; }
}
.switch--sm { @include switch-size(36px, 20px, 14px); }
.switch--md { @include switch-size(48px, 26px, 20px); }
.switch--lg { @include switch-size(60px, 32px, 26px); }
09Disabled state style
Once the disabled attribute is set, the track drops to filter: grayscale(1) and 45% opacity while its label dims to 55% opacity and the cursor switches to not-allowed. That state never changes again, since a disabled checkbox can't receive a click. Locked settings and conditionally unavailable fields use it to show, not just say, that nothing here responds right now.
.switch__input:disabled ~ .switch__track { filter: grayscale(1); opacity: .45; }
.switch__input:disabled ~ .switch__label { opacity: .55; }
@keyframes fade-loop {
0%, 100% { filter: grayscale(0); opacity: 1; }
50%, 70% { filter: grayscale(1); opacity: .45; }
}
Where it breaks — the trap
Most of these nine avoid animating background-color outright and stack two layers through opacity instead. A directly animated color blends through muddy intermediate values that browsers don't all interpolate the same way, while two solid layers crossing in opacity keep both endpoints exactly as declared. MDN's guide to CSS and JavaScript animation performance is why transform and opacity carry almost this entire set. 06's clip-path and 07's background-position break that pattern on purpose, since both only ask the browser to repaint rather than recompute layout, which keeps them just as cheap as a plain opacity swap.
A narrower trap sits inside 03 alone. Its track measures roughly clamp(64px, 17vw, 76px) against a knob near 24px traveling clamp(36px, 10vw, 44px), leaving generous bare track on either side so a label is never half-covered mid-slide. Shrink that track down toward 01's narrower dimensions without shortening the travel distance to match, and the far label starts peeking out from under the knob before the transition even finishes. The nine files with those exact numbers already dialed in, plus their React equivalents, sit zipped up behind a2ejkfw2 — read off this sentence letter for letter and the archive opens.
Accessibility
All nine start from a real <input type="checkbox" role="switch">, so Tab reaches it and Space flips its value through the browser's own keyboard handling, nothing written by hand:
<label class="switch">
<input type="checkbox" class="switch__input" role="switch" aria-checked="false">
<span class="switch__track"><span class="switch__knob"></span></span>
</label>
The same six lines run behind every item except 09, keeping aria-checked explicit for screen readers that don't yet infer it from checked on their own:
document.querySelectorAll(".switch").forEach(function (s) {
var input = s.querySelector(".switch__input");
var sync = function () { input.setAttribute("aria-checked", String(input.checked)); };
input.addEventListener("pointerdown", function () { s.classList.remove("is-demo"); });
input.addEventListener("change", sync);
sync();
});
:focus-visible gets its own ring rather than leaning on a default outline most themes end up stripping:
.switch__input:focus-visible ~ .switch__track {
outline: 2px solid currentColor;
outline-offset: 3px;
}
Under prefers-reduced-motion: reduce, 01 through 08 drop straight to whichever end state :checked or :active already calls for — the knob's travel, the icon swap, the color fill — without the animated approach getting there. Each demo turns its .is-demo loop and its real transition off together to make that happen. 09 needs a lighter version of that handling: since a disabled state is never driven by a transition, its reduced-motion query only has to silence the preview's fade-loop — the disabled look itself already stays the same no matter the motion setting. MDN's prefers-reduced-motion page documents the operating-system setting that flips this query on.
This isn't the only toggle in the archive — the wider CSS collection holds the rest. The about page spells out what's actually free to read here versus what's inside the zip.
FAQ
Is a css toggle switch accessible without any JavaScript?
Mostly. role="switch" and :checked already give Tab, Space, and a screen reader everything they need to know the control exists and its current state, all through the browser's native checkbox behavior. The six lines of JavaScript in items 01 through 08 only close a smaller gap, keeping aria-checked explicit for screen readers that don't yet read it straight off checked. Those eight switches would still work, just with a thinner safety net, if that script never ran; item 09 carries no such script at all, since a permanently disabled switch has nothing left to toggle.
What actually separates a toggle switch from a checkbox in CSS?
Nothing in the markup — every one of these nine is still an <input type="checkbox"> underneath. Adding role="switch" changes only what a screen reader announces, from "checkbox" to "switch." That matters because a switch implies the change takes effect immediately, while a checkbox often waits for a save button somewhere else on the page.
Do the React versions in the zip handle state the same way?
Eight of the nine swap the checkbox's native :checked for a useState boolean; the ninth skips that entirely, since a permanently disabled switch has no state to track. All nine read duration, easing, and color back in as props instead of SCSS variables, and the underlying CSS selectors don't change. Editing those three props produces a differently timed or colored switch without touching a single class name.