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

CSS Switch Button 9 Ways — Rocker to Tape Eject

A css switch button is a control drawn to behave like the physical switch it stands in for: a lever that tilts, a knob that turns, a key that pops a tape out.

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

The order climbs from a flat panel to a whole machine. The first two are binary tilts on one axis: a lever that pivots on its dome (01) and a rocker whose pressed side sinks (02). The next two hold continuous values you drag: a rotary dial that turns a tick ring (03) and a channel fader that cushions at each end (04). Then comes depth: an emergency button that sinks into the panel (05) and a knob you pull down until a lid gives way (06). The last three are scenes with consequences — a plank that tips toward your choice (07), a breaker handle that falls with real weight (08), and a tape deck that spits out its cassette (09). Stage colors cycle through ink, yellow, orange, and paper across the nine. If you came for motion on press rather than hardware imitation, nine button animations and the button design collection are the neighbors; what each control does after the click — loading, saving, done — is button state feedback, and smaller round controls live in icon button patterns.

01Toggle lever switch

A lever that pivots on its dome

A metal lever tilts around its pivot to choose on or off, and on release it springs once before settling — the wiring an audio rack power panel wants, with the on state shown as both an LED dot and the word ON, announced through role="switch" and aria-checked.

Toggle lever switch
Open in a new tab

01 · Toggle lever switch — Measured area 4.119% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password

role="switch"aria-checkedcubic-bezier(.2,1.4,.4,1)linear-gradient
.tl__armWrap {
  position: absolute; left: calc(50% - 5px); bottom: 15px; width: 10px; height: 80px;
  border-radius: $r-pill; transform-origin: 50% 100%; transform: rotate(-24deg);
  transition: transform $duration cubic-bezier(.2, 1.4, .4, 1);
  background: linear-gradient(90deg,
    rgba($stage-ink, .55), rgba($subject-cream, .82) 42%,
    rgba($subject-cream, .95) 50%, rgba($subject-cream, .70) 62%, rgba($stage-ink, .50));
  box-shadow: 0 4px 8px rgba($stage-ink, .55);
}
.tl.is-on .tl__armWrap { transform: rotate(24deg); }

Why the word swap is an opacity cut

The origin sits at 50% 100% — the bottom of the arm, the center of the dome it stands on — so ±24 degrees reads as tilting rather than sliding, and the whole spring-back is one transition whose bezier second value of 1.4 overshoots and returns, caught by the measurement as movement in 18 of 23 frame pairs with a cumulative 4.119% of pixels ever changing; meanwhile the state word can only cut, because display never interpolates, so the loop stacks both words in one box and cuts opacity with steps(1, end) — the old word gone in one frame, the new one fully there in the next, and the static path keeps its plain display rules.

.tl.is-demo .tl__wordOn { display: flex; opacity: 0; }
.tl.is-demo .tl__wordOff { opacity: 1; }
.tl.is-demo .tl__wordOn {
  animation-name: tlWordOn; animation-duration: $dur-loop;
  animation-timing-function: steps(1, end); animation-iteration-count: infinite;
}
@keyframes tlWordOn {
  0%   { opacity: 0; }
  46%  { opacity: 1; }
  70%  { opacity: 0; }
  100% { opacity: 0; }
}

02Rocker power switch

Two caps whose shadow lists must match

Press the top face of the rocker and the pressed side sinks while its shadow thins and the far side rises, so the live side reads without color too, through an amber light and the word ON — the shape of a back-panel power switch mockup.

Rocker power switch
Open in a new tab

02 · Rocker power switch — Measured area 3.633% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password

role="switch"aria-checkedtranslateYinset
.rs__capTop {
  border-radius: $r-sm $r-sm 0 0;
  background: linear-gradient(180deg, $color, rgba($subject-ink, .10)), $color;
  transform: translateY(-3px);
  box-shadow: inset 0 -3px 6px rgba($subject-ink, .10), inset 0 2px 0 rgba($stage-paper, .85);
}
.rs__capBottom {
  border-radius: 0 0 $r-sm $r-sm;
  background: linear-gradient(180deg, rgba($subject-ink, .05), rgba($subject-ink, .17)), $color;
  transform: translateY(3px);
  box-shadow: inset 0 4px 8px rgba($subject-ink, .32), inset 0 -1px 0 rgba($stage-paper, .40);
}
.rs.is-on .rs__capTop {
  transform: translateY(3px);
  box-shadow: inset 0 4px 8px rgba($subject-ink, .32), inset 0 -1px 0 rgba($stage-paper, .40);
}

The overshoot that makes it plastic

Both caps carry two inset layers in the same order and the on-state overrides reuse exactly that structure, because a box-shadow transition only interpolates when the lists match layer for layer — per MDN's box-shadow notes — and this is the quietest of the nine at 8 of 23 frame pairs moved and 3.633% cumulative area, the whole trip being three pixels of translateY plus a shadow shift.

@keyframes rsPressTop {
  0%   { transform: translateY(-3px); }
  12%  { transform: translateY(4px); }
  18%  { transform: translateY(3px); }
  46%  { transform: translateY(3px); }
  58%  { transform: translateY(-4px); }
  64%  { transform: translateY(-3px); }
  100% { transform: translateY(-3px); }
}

The preview presses each side in turn, each press going one pixel past its resting place before settling — a hard plastic cap with a hair of over-travel.

03Rotary dial knob

One property that makes the arc animatable

Drag the css rotary knob and the value rises and falls with the angle while the needle on the tick ring follows, announcing minimum, maximum, and current value together through role="slider" — the contract a lighting brightness panel expects.

Rotary dial knob
Open in a new tab

03 · Rotary dial knob — Measured area 13.382% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

role="slider"aria-valuenowpointermoveconic-gradient
@property --fill {
  syntax: '<number>';
  inherits: true;
  initial-value: 0;
}
.dk__arc {
  position: absolute; inset: 7px; border-radius: 50%;
  background: conic-gradient(from 225deg,
    rgba($subject-ink, .82) 0 calc(var(--fill, 0) * 270deg),
    rgba($subject-ink, .10) calc(var(--fill, 0) * 270deg) 270deg,
    rgba($subject-ink, 0) 270deg 360deg);
}

The angle math that keeps drag honest

A custom property animates only when registered, so @property is what lets the progress arc sweep smoothly alongside the needle instead of flipping as a discrete string — and this is the busiest of the nine in keyframes at 9 named animations, with movement in 23 of 23 frame pairs and 13.382% cumulative area.

var MIN = -135, MAX = 135; // degrees, 0 at twelve o'clock
var VAL_TEXT = '{v}%';
function angleOf(x, y) {
  var r = ring.getBoundingClientRect();
  var cx = r.left + r.width / 2, cy = r.top + r.height / 2;
  var deg = Math.atan2(x - cx, cy - y) * 180 / Math.PI;
  return Math.max(MIN, Math.min(MAX, deg));
}
knob.addEventListener('pointermove', function (e) {
  if (!dragging) { return; }
  value = Math.round((angleOf(e.clientX, e.clientY) - MIN) / (MAX - MIN) * 100);
  apply();
});

Math.atan2(x - cx, cy - y) puts zero at twelve o'clock and clockwise positive, matching where the needle points, with the clamp keeping a drag below the dial from snapping the value around; measured on a real pointer path, dragging to the upper right reported aria-valuenow 75 with the knob at 68 degrees and --fill at 0.75; End jumped to 100 at 135 degrees and Home back to 0; arrows step 5 and shift-arrows 10; and pointer capture on pointerdown lets the drag continue outside the knob per the pointer events model and the slider role contract.

04Push fader slider

Travel on the wrapper, cushion on the cap

Push the channel fader sideways and it cushions at each end, squeezing slightly as it arrives, with the value also shown as a number under the scale — mixer console channel control in one strip.

Push fader slider
Open in a new tab

04 · Push fader slider — Measured area 16.537% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

role="slider"aria-valuenowpointerdowntranslateX
.pf__capWrap {
  position: absolute; left: $sp-2; right: $sp-2; top: $sp-1; bottom: $sp-1;
  transform: translateX(calc(var(--x, 0px)));
}
.pf__cap {
  position: absolute; left: 0; top: 0; width: 48px; height: 100%;
  border-radius: $r-control;
  transition: transform $duration $easing;
}
.pf__cap.is-press { transform: scaleX(.82); }

The two transforms are deliberately split: if the wrapper carried the squeeze its center would sit mid-slot and a cap resting at either end would be dragged sideways — a sidestep, not a press — so the cap owns its own scaleX toward its own center wherever it stands, over a measured 288px of travel against a 48px cap, with movement in 23 of 23 frame pairs and 16.537% cumulative area — in motion in every sampled pair, a distinction shared only with the dial.

The cushion fires only at the ends

The cushion is not a bounce at a wall but the cap compressing 18% for 180ms the moment it reaches an end — the felt stop at the bottom of a real fader — and keyboard users get the same beat because Home and End call cushion() too, landing on the same 0-to-10 scale shown by the numbers below the slot.

function cushion() {
  cap.classList.add('is-press');
  clearTimeout(pressTimer);
  pressTimer = setTimeout(function () { cap.classList.remove('is-press'); }, 180);
}
well.addEventListener('pointermove', function (e) {
  if (!well.hasPointerCapture(e.pointerId)) { return; }
  var t = travel();
  var px = Math.min(t, Math.max(0, startPx + (e.clientX - startX)));
  if ((px === 0 && lastPx > 0) || (px === t && lastPx < t)) { cushion(); }
  apply(px);
});

05Emergency bang button

Sinking with translateZ, not shrinking

Press the red button and it sinks deep into the perspective scene as the base shadow tightens and the warning lamp lights, the press state itself carried by aria-pressed — an emergency-stop control mockup in one button.

Emergency bang button
Open in a new tab

05 · Emergency bang button — Measured area 4.902% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password

aria-pressedperspectivetranslateZbox-shadow
.bb__well { position: relative; display: flex; justify-content: center; perspective: 600px; }
.bb__btn {
  position: relative; display: block; width: clamp(88px, 24vw, 108px); height: clamp(64px, 17vw, 80px);
  border: 0; border-radius: 50%; padding: 0; cursor: pointer; touch-action: manipulation;
  transition: transform $duration $easing, box-shadow $duration $easing;
}
.bb__btn[aria-pressed="true"] { transform: translateZ(-120px); }
.bb.is-on .bb__shadow {
  transform: translateX(-50%) scaleX(.55); opacity: .55;
}

The button does not shrink; it recedes — perspective sits on the well and the button is its direct child, the only arrangement where translateZ(-120px) reads as depth, measured after a real click as matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, -120, 1) and back to none on the second click, while the base shadow narrows to 55% of the well and loses nearly half its opacity because less of the button faces the light down there.

The lamp flickers while the machine runs

The warning lamp is the only element here on linear timing rather than a spring — a strobing radial-gradient glow that starts once the button is fully down and stops before it returns, with the state words cutting between idle and active at the same 14% mark so the lamp never flickers over an unpressed button.

@keyframes bbBlink {
  0%, 9%   { opacity: 0; }
  12%      { opacity: .45; }
  18%      { opacity: 1; }
  24%      { opacity: .5; }
  30%      { opacity: 1; }
  36%      { opacity: .5; }
  42%      { opacity: 1; }
  47%, 100% { opacity: 0; }
}

06Pull-to-open knob

One variable drives pull and stretch

Pull the knob down and it stretches a beat before the lock gives and the lid opens; let go and it returns home with the opened state announced through aria-expanded — a machine-lid opening control or a safe-unlock widget.

Pull-to-open knob
Open in a new tab

06 · Pull-to-open knob — Measured area 20.842% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

aria-expandedpointerupscaletransition
.pk__knob {
  position: relative; width: clamp(56px, 15vw, 68px); height: clamp(36px, 10vw, 44px);
  border: 0; border-radius: $r-control; padding: 0; cursor: grab; touch-action: none;
  transform-origin: 50% 0;
  transform: translateY(calc(var(--pull, 0) * 1px)) scale(1, calc(1 + var(--pull, 0) / 220));
  transition: transform $dur-base $easing;
}

The knob hangs from its stem so the stretch origin is the top edge rather than the center — a non-centered origin for an element attached to something — and a single --pull variable drives both the descent and the elongation at 1/220 per pixel, with the transition switched off during a drag so the knob tracks the finger exactly: measured at full pull, matrix(1, 0, 0, 1.2, 0, 44), 44px down and 20% longer, with the release threshold at 30px and a tap that moves less than 6px simply toggling.

The lid hinges on its far edge

transform-origin: 50% 0 puts the hinge along the lid's top edge so rotateX(-76deg) tips it up and away like a hatch on a top hinge, and the probe confirmed the whole sequence on a real drag — lid open, state word flipped, knob returned home once the pointer is up, because the lock stays open without it — which makes this item the loop's largest single-frame change at 18.543% of the canvas in one frame pair and 20.842% cumulative.

.pk__lidWrap { position: relative; perspective: 520px; width: 100%; }
.pk__lid {
  position: absolute; inset: 0; border-radius: $r-sm; transform-origin: 50% 0;
  transform: rotateX(0deg);
  transition: transform $dur-base $ease-pop;
}
.pk.is-open .pk__lid { transform: rotateX(-76deg); }

07Seesaw tilt switch

A plank that rotates around its bolt

With a pivot in the middle, pressing either side tilts the board that way so the angle itself shows which side is chosen — an A/B option picker with a physical argument, the pick marked by both a pressed look and text.

Seesaw tilt switch
Open in a new tab

07 · Seesaw tilt switch — Measured area 19.144% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

aria-pressedrotatetransform-origintransition
.ss__plank {
  position: absolute; left: 0; right: 0; top: clamp(20px, 6.5vw, 26px); height: clamp(42px, 12vw, 52px);
  background: linear-gradient(180deg, rgba($subject-cream, .55), rgba($subject-cream, 0) 32%, rgba($stage-ink, .18)), $color;
  border: 1px solid rgba($stage-ink, .25); border-radius: $r-control;
  transform: rotate(-8deg);
  transform-origin: 50% 84%;
  transition: transform $duration $easing;
}
.ss[data-side="b"] .ss__plank { transform: rotate(8deg); }

The origin at 50% 84% is where the plank meets the bolt cap on the plinth below it, at the contact point slightly above the visual bottom edge, and both pads are real <button> elements sharing aria-pressed between them — measured tilt a clean ±8 degrees on side picks, with Space picking B at +8 and Enter returning A at -8.

Three signals, none of them color

Which side won travels on three channels at once — the plank's angle, the winning pad sitting 3px lower in a deeper inset shadow, and the A or B letter swapping by a visibility cut — and no signal is color alone, which is what WCAG 1.4.1 asks; all the text on this orange stage is ink #17141a on #ff4d1f, measured 5.50:1, the tightest passing row of the episode's 36-row contrast table.

.ss[data-side="a"] .ss__pad--a,
.ss[data-side="b"] .ss__pad--b {
  transform: translateY(3px);
  box-shadow: 0 2px 4px rgba($stage-ink, .22), inset 0 3px 8px rgba($stage-ink, .32);
}
.ss__letter--b { visibility: hidden; }
.ss[data-side="b"] .ss__letter--b { visibility: visible; }
.ss[data-side="b"] .ss__letter--a { visibility: hidden; }

08Breaker switch

The weight is a two-value transition

Flip the handle down and it falls with real weight past hazard stripes as the OFF mark lights, the catch mid-flip coming from a single-step easing — a distribution-board breaker mockup with mass.

Breaker switch
Open in a new tab

08 · Breaker switch — Measured area 7.954% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

role="switch"aria-checkedrepeating-linear-gradientsteps(1,end)
.bk__lever {
  position: absolute; left: 50%; top: 50%; width: clamp(34px, 9vw, 40px); height: clamp(76px, 20vw, 88px);
  background: linear-gradient(90deg, rgba($stage-ink, .32), rgba($subject-cream, .5) 28%, rgba($subject-cream, .16) 55%, rgba($stage-ink, .38)), $color;
  border: 1px solid rgba($stage-ink, .45); border-radius: $r-sm;
  transform: translate(-50%, -50%) rotate(-26deg);
  transform-origin: 50% 50%;
  transition: transform $duration steps(1,end);
}
.bkWrap[data-on="false"] .bk__lever { transform: translate(-50%, -50%) rotate(26deg); }

The handle pivots on the pin at its center through ±26 degrees and the live transition is steps(1, end) — one value until the interval closes, then the next — which is exactly the mechanical feel sought: your finger completes its press, the handle resists a beat, then drops, measured on real clicks as -26 to +26 and back with the lit LED swapping visibility in the same instant, the preview's plate dipping 2px under the thud of each fall.

Why the cut needs a middle keyframe

steps(1, end) changes value only at the end of each keyframe interval, so authoring the flip at 0% and 100% alone would hold the first angle for the entire loop — a still frame with a moving hand — which is why the cut sits at 27% and 70%, middle keyframes falling between the 12fps sample points (25.0, 29.17, 66.67, 70.83%) where the lamp and status words flip in the same gap as the handle; sampling the finished loop 24 times counted the handle up in 14 samples and down in 10, both states present and no sample where lamp, words, and angle disagreed.

@keyframes bkLever {
  0%, 25%   { transform: translate(-50%, -50%) rotate(-26deg); }
  27%, 68%  { transform: translate(-50%, -50%) rotate(26deg); }
  70%, 100% { transform: translate(-50%, -50%) rotate(-26deg); }
}

09Cassette eject button

The pop is one overshoot bezier

Press the eject key and the tape pops out, shakes once and holds; press again and it swallows back in; whether it is out reads through aria-pressed — retro media handling in one control.

Cassette eject button
Open in a new tab

09 · Cassette eject button — Measured area 19.301% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

aria-pressedcubic-bezier(.2,1.8,.3,1)translateYkeyframes
.cs__tape {
  position: absolute; left: 50%; bottom: $sp-2;
  width: clamp(150px, 42vw, 174px); height: clamp(84px, 23vw, 96px); box-sizing: border-box;
  background: linear-gradient(180deg, rgba($subject-cream, .35), rgba($subject-cream, 0) 30%, rgba($stage-ink, .2)), $color;
  border: 1px solid rgba($stage-ink, .35); border-radius: $r-sm;
  transform: translateX(-50%);
  transition: transform $duration cubic-bezier(.2, 1.8, .3, 1);
}
.cs[data-out="true"] .cs__tape { transform: translateX(-50%) translateY(var(--pop)); }

The lunge comes entirely from the easing — a second value of 1.8 pushes the tape past its resting height and back, so the single live transition reads as pop-then-settle with no extra keyframes — measured as a click moving the tape from 0 to -46px, the --pop variable, with the loop's 24 samples catching it out in 16 and home in 8, the return completing inside 1416.67ms, and the highest mean brightness change of the nine at 113.7 because a cream tape leaving a near-black deck is the biggest contrast event on the page.

Narrow screens pop shallower

The pop height is a variable precisely so a 320px viewport can trade 20px of throw for staying in frame, the deck's top padding shrinking with it, while the rest of the mechanism does not change — the label, reels and mouth are positioned inside the tape, so a shallower pop is the same cassette, just not as far out of the well.

.cs {
  --pop: -46px;
  padding-top: $sp-8; width: 100%; max-width: 360px; box-sizing: border-box;
}
@media (max-width: 340px) {
  .cs { --pop: -26px; padding-top: $sp-4; }
}

Where it breaks — geometry interpolates, state cuts

The one split that runs through all nine

Every control here moves on two rails at once, and the rails obey different physics. Position, angle, and depth are interpolated — a transition or a spring bezier carries them through every in-between frame. State is cut — on or off, A or B, in or out, with nothing in between. All nine break in predictable ways when the rails are crossed: an eased opacity on a swapping word leaves a half-transparent ghost of both words; a display rule inside an animation never animates at all; a box-shadow whose layer lists differ between states snaps instead of blending (02); and a steps(1, end) cut authored only at 0% and 100% freezes the loop at its first value (08). The fix is always the same discipline — words and lamps cut with steps or visibility, shapes tween, and never the twain meet. The nine files, vanilla SCSS and HTML plus a React port of each, travel in one password-protected zip whose password is 8a45qw2n, the same one printed on the download card, so nothing here depends on transcribing code out of a screenshot.

Three variants and accessibility

Three variables retune all nine

Name Value changed What it feels like
duration $dur-quick 220ms → 120ms Lever and rocker drop like machined parts snapping home
easing $ease-spring → $ease-pop Eject and bang button gain one extra step of force
color $stage-paper → $stage-yellow Plastic bodies go from cream to industrial yellow

Each demo declares the three at the top of its SCSS, so a retune is three edits per file and the React ports read the same tokens.

What reduced motion leaves standing

Under prefers-reduced-motion: reduce every loop stops on one honest frame and every transition on the live path is dropped: 01 holds the lever at -24 degrees with the LED dark; 02 rests in its off tilt; 03 keeps the knob at its current --angle with the real value digit visible instead of the loop's cut digits; 04 centers the cap; 05 leaves the button raised and the strobe off; 06 stands closed; 07 tilts the plank left; 08 pins the handle up with the ON mark lit; 09 seats the tape home. The reasoning follows WCAG's animation from interactions: nothing here conveys information only through motion, so a still frame loses no state.

Reading state without color

Color is never the only carrier: 01 pairs its LED with the word ON, 02 its amber lamp with ON text, 03 prints the number and points the needle, 04 bolds and underlines the current scale number, 05 swaps idle and active words over aria-pressed, 06 fills a dot and swaps lock and open words, 07 tilts the plank, 08 aims the handle and lights a lamp beside the words, 09 leaves the tape visibly out and the key visibly pressed. The contrast table was measured on the rendered pixels of all nine, composited over their actual backgrounds — all 36 rows pass AA, and the tightest are the ink-on-orange rows:

Where Text Background Ratio
03 scale digits and label #17141a #ff4d1f 5.50:1
07 hint line and output label #17141a #ff4d1f 5.50:1
09 SIDE A label #585556 #fffbf1 7.15:1
07 pad sub-label #58565a #ffffff 7.27:1

FAQ

Can I paste a css switch button from this page straight into my project?

Yes — every fence above is copied verbatim from the demo source, not retyped, so what you copy is what runs in the grid. The one edit a standalone page needs is the @use line at the top of each SCSS file, which points at the shared token sheet for colors, radii and durations; either keep that file next to your build or replace the tokens with literal values. The zip carries all nine folders plus a React port of each, and the small-print strings inside the demos are language-packed, so an English page shows English labels without touching the code.

How do I wire the css knob to a real value like volume?

The knob already speaks value: role="slider" with aria-valuemin, aria-valuemax and aria-valuenow updated on every drag and key press, and the current number in the readout. To turn it into a css volume knob, hook the same apply() function — write value / 100 to your gain node, or set a --vol custom property for your CSS, and the dial becomes the front end of whatever uses the number. Nothing about the angle math changes; the value it produces is just real now.

Do any of the nine need JavaScript to flip?

The six binary ones — lever, rocker, bang, seesaw, breaker, eject — each runs on one short handler that toggles an aria attribute and a state class, which is also what detaches the preview loop on first touch. The three continuous ones — dial, fader, pull knob — compute their values from pointer geometry, so their script is the feature rather than a shim. In every case the visual layer is CSS alone, and the reduced-motion frame needs no script at all.

Enter the archive password

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