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

CSS Toggle Switch: 9 Realistic Switches to Paste

A css toggle switch is the small on-off control visitors flip without thinking, and here are nine built with no library, just a checkbox.

Auto-plays · click a tile to jump to its section · all nine in one zip

The order is not a popularity list — it is the order in which a switch gains its physical feel. First comes press and light (01), then glass (02), then personality (03), then a calm matte finish (04), then two slots to choose between (05), then flowing light (06), then a hidden band uncovered by the slide (07), then a checkmark that settles the agreement (08), and finally inertia that follows your finger (09). Eight announce their state with role="switch" and aria-checked while the two-slot picker (05) uses aria-pressed, and the on state is readable from position, words, and light, never from color alone. For the hardware-flavored parts see 9 CSS Switch Buttons, and for flat toggle fundamentals see 9 CSS Toggle Switches.

01Power button with LED ring

A round button stacked from concentric bevels that sinks when pressed as the power-symbol ring on its rim bleeds light, for the main power on a device panel.

Power button with LED ring
Open in a new tab

01 · Power button with LED ring — Measured area 5.355% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password

box-shadowtranslateYaria-checked

On and off take different amounts of time

// LED ring — turning on, it spreads for $dur-base starting $dur-quick after the cap returns;
// turning off it dies fast with no delay (one-way trick: a transition rule per direction).
.pw__led {
  position: absolute; inset: 22px; z-index: 2; border-radius: 50%; pointer-events: none;
  box-shadow: inset 0 0 0 3px rgba($color, .14);
  transition: box-shadow $dur-quick $ease-out;
}
.pw.is-on .pw__led {
  box-shadow: inset 0 0 0 3px $color, 0 0 16px rgba($color, .6), 0 0 34px rgba($color, .28);
  transition: box-shadow $dur-base $ease-out $duration;
}

The base rule owns the off direction (short, no delay) and the .is-on rule overrides it for on (a 300ms spread that starts 220ms after the button returns), so one transition rule per direction is all it takes to produce the asymmetry of a real power button.

The press deepens the shadow with it

.pw__cap:active {
  transform: translateY(4px) scale(.98);
  box-shadow: 0 2px 4px rgba(0, 0, 0, .6), inset 0 1px 0 rgba($subject-cream, .22), inset 0 -2px 4px rgba(0, 0, 0, .6);
}

The cap drops 4px while its outer shadow collapses — press depth is information your hand notices before your eyes do — so transform and box-shadow share one duration.

02Vertical glass-gloss toggle pair

Two glossy knobs slide up and down inside upright grooves, one per equipment-panel channel, and they sit in opposite states so you can compare 0 and 1 side by side.

Vertical glass-gloss toggle pair
Open in a new tab

02 · Vertical glass-gloss toggle pair — Measured area 10.045% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

linear-gradienttranslateY:checked

The checkbox decides the state itself

.ch__input:checked ~ .ch__track .ch__knob {
  transform: translateY(var(--travel));
  box-shadow: 0 9px 14px rgba($stage-ink, .34), inset 0 -3px 6px rgba($stage-ink, .28), inset 0 1px 0 rgba($stage-paper, .9);
}

The input is a single <input type="checkbox" role="switch">, so the toggle needs zero lines of JS, the :checked sibling selector drives knob position, shadow, and digit emphasis at once, and Space comes free with the checkbox.

Glass reads from one specular dot

.ch__knob {
  position: absolute; left: 9px; top: 9px; width: 46px; height: 46px; border-radius: 50%;
  background: linear-gradient(180deg, rgba($stage-paper, .95), rgba($color, .6) 45%, rgba($stage-paper, .35) 78%, rgba($subject-ink, .22));
  box-shadow: 0 3px 5px rgba($stage-ink, .4), inset 0 -3px 6px rgba($stage-ink, .28), inset 0 1px 0 rgba($stage-paper, .9);
  transform: translateY(0);
  transition: transform $duration $easing, box-shadow $duration $easing;
}
.ch__knob::before {
  content: ""; position: absolute; left: 16%; top: 12%; width: 44%; height: 32%;
  border-radius: 50%;
  background: radial-gradient(closest-side, rgba($stage-paper, .95), rgba($stage-paper, 0));
}

A vertical gradient plus one upper-left elliptical specular highlight reads as glass, and the shadow of the settled state spreads farther down than the raised state's, which is itself the clue that the knob has reached the bottom of the groove.

03Rolling toggle that opens its eyes

Instead of sliding, the knob rolls across to the other side and its two shut eyes open once it arrives, for a sound toggle in a kids' app.

Rolling toggle that opens its eyes
Open in a new tab

03 · Rolling toggle that opens its eyes — Measured area 9.99% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

rotatescaleYcubic-bezier

Travel and spin live on two layers

// tr__knob only translates, tr__roll only rotates — the eyes sit outside the rotation
// so they arrive upright no matter how far the knob rolled.
.tr__knob {
  position: absolute; left: 9px; top: 8px; width: 69px; height: 69px;
  transform: translateX(0);
  transition: transform $duration $easing;
}
.tr__roll {
  position: absolute; inset: 0; border-radius: 50%;
  background: linear-gradient(160deg, $color, rgba($color, .88) 45%, rgba($subject-ink, .16) 92%);
  box-shadow: 0 4px 8px rgba($stage-ink, .45), inset 0 2px 2px rgba($stage-paper, .8), inset 0 -3px 5px rgba($stage-ink, .22);
  transform: rotate(0deg);
  transition: transform $duration $easing;
}
.tr.is-on .tr__knob { transform: translateX(var(--travel)); }
.tr.is-on .tr__roll { transform: rotate(var(--roll)); }

The outer layer travels 163px while the inner one turns 271deg over the same time, and since the angle is only travel divided by radius, it does not have to be exact — when both movements end together, it reads as a wheel.

The eyes sit outside the spin axis

.tr__eye {
  width: 8px; height: 8px; border-radius: 50%; background: $subject-ink;
  transform: scaleY(0);
  transition: transform $dur-quick $ease-pop;
}
.tr.is-on .tr__eye { transform: scaleY(1); }

The eyes live on a layer above the rolling disc, so they stay upright through the roll, and the closed state is a thin scaleY(0) line that would skew apart if it were caught in the spin.

04Three matte keycap toggles

Three nearly square keycaps sit in a row, and pressing one sinks it while it fills with blue, for on-off buttons in a toolbar.

Three matte keycap toggles
Open in a new tab

04 · Three matte keycap toggles — Measured area 7.817% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

box-shadowbackground-colortransition

Press and color fill get separate clocks

.eq__key {
  display: flex; align-items: center; justify-content: center;
  width: 62px; height: 62px; margin: 0; padding: 0;
  border: 0; border-radius: $r-control; cursor: pointer;
  color: $subject-ink; // the icon (currentColor) inherits from the key — the eqIcon keyframes drive this color
  background-color: $cap-off;
  box-shadow: $cap-rest-shadow;
  transform: translateY(0);
  transition: transform $dur-instant $easing, box-shadow $dur-instant $easing, background-color $duration $ease-out, color $duration $ease-out;
}
.eq__key.is-on { background-color: $color; color: $subject-cream; }

The press (transform, box-shadow) takes 140ms and the fill takes 220ms, so the click lands first and the color chases it.

Three keys, one keyframe set, negative delays

.eq.is-demo .eq__key {
  animation-name: eqPress, eqFill, eqIcon;
  animation-duration: $dur-loop;
  animation-timing-function: $easing, steps(1, end), steps(1, end);
  animation-iteration-count: infinite;
}
.eq.is-demo .eq__key:nth-child(2) { animation-delay: calc(#{$dur-loop} / -3 * 2); }
.eq.is-demo .eq__key:nth-child(3) { animation-delay: calc(#{$dur-loop} / -3); }

One keyframe set serves all three keys because the animation-delay shifts each by a third of the loop, and a negative delay means no waiting period, so no fill-mode is needed either.

05Selector pill sliding between two slots

A dark pill slides between two labels inside a long track and only the label it lands on flips its color, for picking one of two ranges such as day or week.

Selector pill sliding between two slots
Open in a new tab

05 · Selector pill sliding between two slots — Measured area 8.249% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

grid-template-columnstranslateXsteps(1, end)

Slots are an even grid, travel is a fixed distance

.sg__track {
  --travel: 116px; // slot width = track 240 minus 8 side padding, halved. 96px on narrow screens
  display: grid; grid-template-columns: repeat(2, 1fr);
  position: relative;
  width: 240px; height: 48px; padding: $sp-1;
  border-radius: $r-pill;
  background: $subject-blue-deep;
}
.sg.is-week .sg__knob { transform: translateX(var(--travel)); }

Because the indicator moves by a fixed pixel distance, the two slots use repeat(2, 1fr) — flex: 1 1 0 cells can render at widths that drift off the fixed value and leave the pill landing off its label — and on narrow screens a media query shrinks the track, the pill, and --travel together.

A metal face is one bright-over-dark layer

.sg__knob {
  position: absolute; left: 8px; top: 4px;
  width: 108px; height: 40px; border-radius: $r-pill;
  background: linear-gradient(180deg, rgba($subject-cream, .16), rgba($subject-cream, .02) 34%, rgba(0, 0, 0, .38)), $subject-ink;
  box-shadow: inset 0 1px 0 rgba($subject-cream, .3), 0 4px 10px rgba(0, 0, 0, .5);
  transform: translateX(0);
  transition: transform $duration $easing;
}
.sg__cell[aria-pressed="true"] { color: $subject-cream; }

The pill is an ink base with one top gloss layer and vertical grips at both ends, each slot is a <button aria-pressed> so Tab and Enter switch it too, and only the label under the pill deepens when it lands.

06Glass toggle with a light sweep

As the knob crosses the track, a pool of light sweeps diagonally over the glass and the specks brighten when it arrives, for one large night-mode switch in the middle of a dark screen.

Glass toggle with a light sweep
Open in a new tab

06 · Glass toggle with a light sweep — Measured area 11.507% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password

radial-gradienttranslateXbox-shadow

The light overlays an empty track

.onx__light {
  position: absolute; inset: 0; border-radius: inherit; pointer-events: none;
  background: radial-gradient(120% 150% at 32% 50%, rgba($color, .96), rgba($subject-blue-deep, .92) 62%, rgba($subject-blue-deep, .8));
  opacity: 0;
  transition: opacity $dur-enter $ease-out;
}
.onx.is-on .onx__light { opacity: 1; }

The track sits empty while the switch is off, and a radial gradient with a bright center fills it in over 600ms when it turns on, with track, light, and knob on separate layers so the glow never tints the knob itself.

The reflection drifts to the far side

.onx__sweep {
  position: absolute; left: 44%; top: 12%; width: 52%; height: 76%;
  border-radius: 50%; pointer-events: none;
  background: linear-gradient(52deg, rgba($stage-paper, 0) 34%, rgba($stage-paper, .46) 56%, rgba($stage-paper, 0) 78%);
  transform: translateX(0) rotate(0deg);
  transition: transform $dur-enter $ease-out;
}
.onx.is-on .onx__sweep { transform: translateX(-76%) rotate(-9deg); }

A diagonal band of reflection slides left and tilts by 9 degrees the moment the switch turns on, which reads as the light source moving across the glass, while five star dots switch on at arrival and the state words stay ink-dark on the yellow stage.

07Slide-across mute switch

Push the handle to the end and an orange band hidden inside the groove is uncovered, for an easily reversible action such as muting alerts.

Slide-across mute switch
Open in a new tab

07 · Slide-across mute switch — Measured area 4.199% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

insettranslateXsteps(1, end)

The hidden band tells the state

.sm__rail {
  position: relative; box-sizing: border-box;
  width: 124px; height: 48px; border-radius: $r-pill;
  background: linear-gradient(180deg, rgba($subject-ink, .95), rgba($subject-ink, .78));
  box-shadow: inset 0 3px 6px rgba($stage-ink, .9), inset 0 -1px 0 rgba($subject-cream, .22);
}
.sm__band {
  position: absolute; left: 4px; top: 4px; width: 56px; height: 40px;
  border-radius: $r-pill;
  background: linear-gradient(180deg, $color, rgba($stage-ink, .55));
  box-shadow: inset 0 2px 3px rgba($stage-ink, .5);
}

The band is laid inside the rail from the start and merely covered by the handle, so when the handle moves to the far end the uncovered patch says "muted" through exposure rather than through color.

It snaps past the end slot once

.sm__handle {
  --travel: 56px; // distance to the far slot; a narrower screen shrinks it below
  transition: transform $duration $easing;
}
.sm.is-muted .sm__handle { transform: translateX(var(--travel)); }

The $ease-pop easing carries the handle just past the slot and settles it back, and because a hardware slide never rests halfway, the move is a single transition, with the narrow-screen query shrinking the rail, the handle, and --travel together.

08Toggle that sets into a check

The check line draws itself only after the knob has arrived, for a control where commitment matters, such as agreeing to terms.

Toggle that sets into a check
Open in a new tab

08 · Toggle that sets into a check — Measured area 3.276% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password

stroke-dashoffsetanimation-delaypolyline

The check is one SVG stroke

<button class="cm__switch" id="cmSwitch" type="button" role="switch" aria-checked="false" aria-label="Terms agreement">
  <span class="cm__knob" aria-hidden="true">
    <svg class="cm__check" viewBox="0 0 24 24" aria-hidden="true">
      <polyline points="5,12.5 10,17.5 19,7.5"></polyline>
    </svg>
  </span>
</button>

Faking a checkmark from two bars tends to misalign their origins into a single diagonal, while one SVG polyline with stroke-dasharray: 26, its stroke-dashoffset eased from 26 to 0, draws a single exact stroke with a correct corner.

Drawing starts only after the travel ends

.cm__knob {
  position: absolute; left: 4px; top: 4px; display: grid; place-items: center;
  width: 40px; height: 40px; border-radius: 50%;
  background: $stage-paper;
  box-shadow: 0 2px 4px rgba($subject-ink, .34);
  transform: translateX(0);
  transition: transform $duration $easing;
}
.cm.is-agreed .cm__knob { transform: translateX(var(--travel)); }
.cm.is-agreed .cm__check polyline { stroke-dashoffset: 0; transition-delay: $duration; }

Arrival and confirmation are different signals, so the check transition carries transition-delay: $duration and only draws once the knob has fully landed, while switching off erases the check first with no delay.

09Drag-and-release inertia toggle

The knob follows your finger while you drag it, and letting go past the halfway mark sends it coasting to the far end, for an unlock you do not want triggered by accident.

Drag-and-release inertia toggle
Open in a new tab

09 · Drag-and-release inertia toggle — Measured area 5.107% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password

pointerdownsetPointerCapturetranslateX

Transitions turn off only while dragging

.dt.is-dragging .dt__knob { transition: none; cursor: grabbing; }
.dt.is-dragging .dt__fill { transition: none; }

During the drag the knob must not lag behind the fingertip, so .is-dragging switches the transition off, and the moment you let go the class drops, the transition returns, and $ease-pop sprints the remaining distance.

Halfway decides where it settles

function settle(open) {
  dtKnob.style.transform = '';
  dtKnob.setAttribute('aria-checked', open ? 'true' : 'false');
  dtRoot.classList.toggle('is-open', open);
  offset = open ? travel() : 0;
}
function onUp() {
  if (!dragging) return;
  dragging = false;
  dtRoot.classList.remove('is-dragging');
  // past halfway the inertia finishes the trip, short of it the knob returns home
  settle(offset > travel() / 2);
}

Release position is judged only against the halfway line, the pointer is bound with setPointerCapture so the drag survives leaving the track, and clicks and Space call the same settle() so the keyboard unlocks too.

How far does a css toggle switch example go with one checkbox?

This far. Keep the state on a checkbox or an aria-checked flag, build the material from a few gradients and box-shadows, and give each css switch on off its own timing and easing — everything from the power button to the inertia toggle runs without a library. Three items play by different rules — the roll in 03, the stroke draw in 08, and the drag in 09 are motion rather than state, so their preview loops and their real input paths must be verified separately.

Where it breaks — switches that only work in the grid

The click after a drag flipped the state

On the first pass of 09, a Playwright drag of 30px and release left aria-checked flipped to true. The browser fires a click event on the same element after a drag ends, and the toggle handler reads it as a fresh tap. Two lines that remember any movement beyond 6px and swallow the following click fixed it — measurement only watches the preview loop, so anything with an input path has to be driven by hand.

A child's own color rule killed the parent's animation

In 04 the icon color was declared directly on .eq__icon, and the third key's icon stayed frozen in its on color through the whole preview loop. The animation was driving the parent key's color, but the child's direct declaration beat inheritance. Moving the color to the key so the icon inherits it put all three keys back on schedule, and nothing in the source looked wrong until the computed values were dumped frame by frame.

A half-filled cap became the poster

Comparing the poster against the rendered frames pixel by pixel showed it had caught the cap mid-fade between gray and blue, with the dark icon floating pale on the half tone. The eased fill leaves a few in-between frames by design, so the cap and icon colors now flip together as a steps(1, end) cut at the same keyframes. Real clicks keep the 220ms fade from the static rules, so the feel in hand is unchanged. The password for the nine files is 3hntnd6x, and every switch was measured through click, Space, and drag.

Three variants and accessibility

Name Changed value Feel
duration $dur-base 300ms → $dur-instant 140ms the knob stops gliding and snaps like a machine part
easing $ease-spring → $ease-pop the knob bounces once at the far end before settling
color $subject-blue-deep → $stage-orange the on state reads as a warning switch

Eight carry role="switch" (02 is a checkbox that exposes aria-checked natively) and the two-slot picker 05 uses aria-pressed. State is signaled by knob position, words, and the uncovered band as well as by color, so the on state survives color-vision differences. In 09 the pointer and keyboard paths end in the same settle(), and under prefers-reduced-motion: reduce the preview loops stop and leave one readable frame of each switch in its off position.

Further reading: 9 CSS Switch Buttons covers levers, rockers, and dials, and the button animation guide sets the timing standards. For the specs see the MDN switch role and :checked.

FAQ

Should a css toggle switch with label use a button or a checkbox

When the control should hold its own state, put role="switch" on a checkbox (item 02), and Space and form submission come free. When the app owns the state, keep aria-checked on a button yourself (items 01, 07, 08, 09).

Is color alone enough to show an icon switch is on

Even when color is the main cue as in 04, press depth and the icon-color flip ride along. Blue-versus-gray is weak for some color-vision types, so add a second cue such as position or words whenever you can.

Does the drag toggle work on touch screens

Yes. Item 09 funnels mouse, touch, and pen through setPointerCapture and blocks scroll during the drag with touch-action: none. Fingers rarely stop exactly at halfway, so confirm by hand that the halfway rule settles where you intended.

Enter the archive password

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