GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

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

The order below tracks how much of each css toggle switch animation 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. To pour the same moving color into 03's label text, the CSS text shine animation set clips a gradient onto letters the same way, and to style the checkboxes and radios that sit under the same form, the custom CSS checkbox and radio animations set starts from this identical <input> markup.

01Basic slide toggle

How it works

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. The knob overshoots that mark by a few pixels before springing back, squashing flat for an instant as it settles, and the track's own shadow deepens then eases as the knob lands.

Where it fits

It's the plain shape for a settings row or any checkbox a form wants to present as a switch instead.

Basic slide toggle
Open in a new tab

01 · Basic slide toggle — Measured area 0.917% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

translateX:checked0.25초
.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

How it works

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, squashing narrower at the peak of the bounce while the track's inset shadow deepens then lifts.

Where it fits

It fits a mobile settings screen where a switch already carries the tactile feel iOS trained people to expect.

iOS-style toggle
Open in a new tab

02 · iOS-style toggle — Measured area 1.118% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

cubic-bezier 스프링그림자0.3초
$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

How a css toggle switch with text keeps its labels

ON and OFF text sits fixed at both ends of the track from the start, and 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. It overshoots the landing point and squashes briefly before settling, and the track's shadow deepens right as it presses down over the letter.

Where it fits

Admin dashboards reach for it when a switch's current value has to read as text, not just a knob's side.

ON/OFF label inside
Open in a new tab

03 · ON/OFF label inside — Measured area 1.507% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

z-indexoverflow:hidden0.25초
.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

How it works

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. The slide itself overshoots the target before springing back and squashing for an instant, with the track shadow deepening at the moment the icons swap.

Where it fits

Permission and notification lists use it since the outcome reads from the icon alone, no label required.

Icon swap toggle
Open in a new tab

04 · Icon swap toggle — Measured area 1.05% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

opacity 크로스페이드SVG 아이콘0.2초
.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

How it works

Position and press live in separate custom properties — --x for where :checked parks the knob, --sx/--sy for how far :active squishes it — and pressing stretches the knob to scaleX(1.35) without disturbing wherever --x already left it. Releasing overshoots back past the resting circle before it springs into place, and the track shadow that deepened under the press eases off with it.

Where it fits

Touch-first forms use the combination so a press reads as a squeeze before the switch has even finished sliding.

Squish press effect
Open in a new tab

05 · Squish press effect — Measured area 0.917% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password

scaleX:active0.15초
.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

How it works

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. The knob squashes with a small spring bounce right as the fill finishes, and the track shadow shifts from shallow to deep to mark the transition.

Where it fits

Premium settings cards and plan-upgrade toggles use that spread to make the switch itself worth watching.

Color-fill background
Open in a new tab

06 · Color-fill background — Measured area 1.033% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

clip-path:circle()번짐0.4초
.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

How it works

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, while three star dots fade in and the knob's sun icon cross-fades to a moon. The knob overshoots and squashes into that moon shape before settling, and the track shadow that sat deep under daylight thins out once night takes over.

Where it fits

Theme switches get more than an icon flip this way, since the whole sky is the control now.

Day/night icon toggle
Open in a new tab

07 · Day/night icon toggle — Measured area 1.343% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

background-position별 opacity0.5초
.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

How it works

One @mixin takes a track width, height, and knob size, and derives a single --travel custom property from them, so small, medium, and large switches all read transform: translateX(var(--travel)) instead of three separately tuned distances. The same overshoot-and-squash spring and shadow-depth curve scale with --travel, so all three sizes land with the same feel.

Where it fits

Design-system components use it so three sizes stay one switch, not three that happen to look similar.

Size variant mixin
Open in a new tab

08 · Size variant mixin — Measured area 2.444% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

@mixinem 단위0.25초
@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

How it works

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 — a state that never changes again, since a disabled checkbox can't receive a click. Even that final fade still carries a small overshoot spring and a shadow that thins as it settles, keeping the same texture as the other eight right up to the freeze.

Where it fits

Locked settings and conditionally unavailable fields use it to show, not just say, that nothing here responds right now.

Disabled state style
Open in a new tab

09 · Disabled state style — Measured area 1.517% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password

filter:grayscaleopacitycursor:not-allowed
.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

Opacity instead of color

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.

The two exceptions

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.

Track width versus labels

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. Matching those track and knob colors to a whole settings panel is easier from a token table than by eye: the Linear style dark SaaS landing sections publish theirs. 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

Native control first

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;
}

Reduced motion

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 separates a css on off 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. The buttons standing beside these switches can share those same three values — the award-winning button hover effects set reuses the springy curve item 05 presses with.

Enter the archive password

The password is inside this article. You will find it as you read.