Introduction
In this article we build RockerToggle — a tactile, hardware-inspired rocker switch built entirely from HTML and CSS. The switch has two bevelled faces that tilt on a shared pivot point using rotateX perspective transforms, a glowing LED indicator that activates on the bottom face, and a multi-layer shadow system that simulates a real cast-plastic housing. Clicking the top face rocks the switch off; clicking the bottom rocks it on. No JavaScript, no SVG, no canvas — just a hidden checkbox, the CSS sibling combinator, and carefully layered box-shadows.
Every visual layer — the housing groove, the bevel sheen, the knob faces, the pivot shadow, the LED glow — is created from plain span elements driven by CSS custom properties. The entire colour palette and timing curve are re-themeable by editing a single :root block. Dark mode is handled automatically via prefers-color-scheme.
How to use a hidden checkbox and the CSS sibling combinator (~) to drive complex state changes without JavaScript — how to fake a 3-D pivot with rotateX and transform-origin — how to build convincing plastic depth using multiple box-shadow layers on a single element — how to animate a glowing LED with box-shadow opacity transitions — how to manage light/dark theming through a single :root token block and prefers-color-scheme — and how to keep every visual detail accessible with focus-visible and role="switch".
Step 1 — CSS Custom Properties
All colour, sizing, and timing values live in :root as custom properties. Two hue variables — --primary-hue and --secondary-hue — control the entire palette. Adjusting --primary-hue slides the housing colour from cool slate to warm taupe. Changing --secondary-hue swaps the LED accent from green to any hue you like. The --transition-duration and --transition-ease tokens rescale every animated property simultaneously.
:root {
--primary-hue: 223; /* hue for the housing & knob (blue-grey slate) */
--secondary-hue: 133; /* hue for the LED indicator (green) */
/* derived surface colours */
--surface: hsl(var(--primary-hue), 10%, 90%); /* page background */
--surface-dark: hsl(var(--primary-hue), 10%, 30%); /* dark-mode surface */
--shadow-color: hsl(var(--primary-hue), 10%, 10%); /* deep shadow tint */
--accent-strong: hsl(var(--secondary-hue), 90%, 45%); /* lit LED colour */
/* timing */
--transition-duration: 0.3s;
--transition-ease: cubic-bezier(0.83, 0, 0.17, 1); /* snappy in, eased out */
}Step 2 — The Markup
The component is a single label element with four descendants: the hidden checkbox input, a .toggle__shadow span (the cast shadow and pivot shade), and a .toggle__knob span that contains two face spans — .toggle__face--top and .toggle__face--bottom. Each face holds a .toggle__indicator child: a circular indent on the top face and an LED bar on the bottom.
<label class="toggle">
<!-- the hidden checkbox — drives all state via the sibling combinator -->
<input class="toggle__input" type="checkbox" role="switch"
name="power" aria-label="Power">
<!-- cast shadow element — positioned to the right of the knob -->
<span class="toggle__shadow"></span>
<!-- the knob — contains both rocker faces -->
<span class="toggle__knob">
<!-- top face: OFF side — shows a circular indent indicator -->
<span class="toggle__face toggle__face--top">
<span class="toggle__indicator"></span>
</span>
<!-- bottom face: ON side — shows the LED bar indicator -->
<span class="toggle__face toggle__face--bottom">
<span class="toggle__indicator"></span>
</span>
</span>
</label>| Element | Role | Animated |
|---|---|---|
| .toggle | Housing — fixed 3.75 × 8.75em, layered box-shadow groove | No |
| .toggle__input | Hidden checkbox — the state machine for the whole component | No |
| .toggle__shadow | Cast shadow disc to the right of the knob, split into two halves | Yes — skewX + rotate on :checked |
| .toggle__knob | Inner plastic block — 6px inset on all sides inside the housing | No |
| .toggle__face--top | Upper rocker face — tilts backward (rotateX 35deg) when ON | Yes — rotateX + background |
| .toggle__face--bottom | Lower rocker face — tilts backward (rotateX -35deg) when OFF | Yes — rotateX + background |
| .toggle__indicator (top) | Circular indent — static depression styled with inset box-shadow | Yes — shadow tint shifts |
| .toggle__indicator (bot) | LED bar — glows green with box-shadow when :checked | Yes — background + box-shadow glow |
Step 3 — Driving State with a Hidden Checkbox
The component has zero JavaScript. All state is owned by a native checkbox input. Because the input is a direct sibling of .toggle__shadow and an ancestor sibling of .toggle__knob (both inside the same label), the CSS sibling combinator (~) can reach every element that needs to change when the checkbox toggles. The input itself is made invisible with opacity: 0 but retains its full bounding box so the entire label surface remains clickable.
The input is invisible, not hidden. Using opacity: 0 instead of display: none or visibility: hidden preserves the clickable hit area across the full label — so tapping anywhere on the switch, including the housing border, registers the toggle.
.toggle__input {
position: absolute; /* removed from flow — overlays the whole label */
inset: 0; /* stretches to cover all four edges of .toggle */
width: 100%;
height: 100%;
opacity: 0; /* invisible but still receives pointer events */
cursor: pointer;
z-index: 2; /* sits above knob faces so clicks always register */
-webkit-tap-highlight-color: transparent; /* no flash on mobile */
}
/* focus-visible ring — only shows for keyboard navigation */
.toggle__input:focus-visible + .toggle__shadow,
.toggle__input:focus-visible + .toggle__shadow + .toggle__knob {
outline: 2px solid hsl(var(--primary-hue), 90%, 45%);
outline-offset: 0.35em;
}
/* all state changes use this combinator pattern: */
.toggle__input:checked ~ .toggle__knob .toggle__face--top { /* ... */ }
.toggle__input:checked ~ .toggle__knob .toggle__face--bottom { /* ... */ }
.toggle__input:checked ~ .toggle__shadow::before { /* ... */ }Step 4 — Building the Knob and its Two Faces
The .toggle__knob sits 0.375em inside the housing on all sides — this gap creates the visible groove that frames the rocker. The knob itself is a rounded rectangle with an inset box-shadow that darkens its left edge and lightens its right, simulating a single overhead light source. Inside the knob, the two face spans each occupy exactly half the knob height. Their transform-origin is set at the shared midpoint — 50% 100% for the top face (pivoting from its bottom edge) and 50% 0 for the bottom face (pivoting from its top edge) — so both tilt around the same invisible hinge.
.toggle__knob {
position: relative;
margin: 0.375em; /* inset from housing edges */
width: calc(100% - 0.75em);
height: calc(100% - 0.75em);
background: hsla(var(--primary-hue), 10%, 80%, 1);
border-radius: 0.25em;
box-shadow:
0 0 0.25em var(--shadow-color) inset, /* deep shadow — left + top */
0.75em 0 0.5em hsl(var(--primary-hue), 10%, 90%) inset; /* light sheen — right */
}
/* shared face rules */
.toggle__face {
position: absolute;
left: 0.125em;
width: calc(100% - 0.25em);
height: calc(50% - 0.125em); /* exactly half the knob */
padding: 0.5em;
display: flex;
justify-content: center;
}
.toggle__face--top {
bottom: 50%; /* upper half */
background: hsl(var(--primary-hue), 10%, 85%);
border-radius: 0.25em 0.25em 0 0;
transform-origin: 50% 100%; /* pivot from bottom edge */
/* default: OFF — face is flat (no rotateX) */
}
.toggle__face--bottom {
top: 50%; /* lower half */
align-items: flex-end;
background: hsl(var(--primary-hue), 10%, 90%);
border-radius: 0 0 0.25em 0.25em;
transform-origin: 50% 0; /* pivot from top edge */
transform: rotateX(-35deg); /* default: tilted away — OFF */
}Step 5 — Simulating a Physical Pivot with rotateX
The illusion of a real rocker switch comes from two rotateX transforms applied in opposite directions at the same pivot point. In the OFF state, the top face is flat (rotateX: 0) and the bottom face is tilted away at -35deg. When :checked, the top face tilts backward at 35deg and the bottom face returns to flat (rotateX: 0). Because both faces share the midpoint of the knob as their transform-origin, they appear to rock around a shared physical hinge.
Both faces set transform-origin to the line where they meet — the exact midpoint of the knob. This means their pivot is co-located, which is what makes the motion read as a single rocking object rather than two independent flaps.
/* ── OFF state (default) ── */
.toggle__face--top { transform: rotateX(0); } /* flat */
.toggle__face--bottom { transform: rotateX(-35deg); } /* tilted away */
/* ── ON state (:checked) ── */
.toggle__input:checked ~ .toggle__knob .toggle__face--top {
background: hsl(var(--primary-hue), 10%, 70%); /* darker: pressed into shadow */
transform: rotateX(35deg); /* now tilted away */
}
.toggle__input:checked ~ .toggle__knob .toggle__face--bottom {
background: hsl(var(--primary-hue), 10%, 85%); /* lighter: facing the light */
transform: rotateX(0); /* now flat and prominent */
}
/* both transitions use the shared token */
.toggle__face {
transition:
background var(--transition-duration) var(--transition-ease),
transform var(--transition-duration) var(--transition-ease);
}A face tilted away from the viewer catches less of an imaginary overhead light source and should appear darker. A face rotating toward the viewer faces the light more directly and should appear lighter. Shifting the background hsl lightness by ±5–15% in sync with the transform sells the 3-D illusion without needing actual WebGL lighting.
Step 6 — Faking Plastic Depth with box-shadow
The housing uses three box-shadow layers on .toggle to create a recessed groove effect. The first is a large inset shadow at the bottom half — as if the interior of the housing is a dark well. The second is a thin bright inset line at the top — simulating the rim of the housing catching overhead light. The third is an outer blur — the ambient drop shadow that lifts the whole component off the page. The cast shadow to the right of the knob is a separate .toggle__shadow element with two pseudo-elements: ::before handles the upper triangular shade cast by the top face, and ::after handles the lower rectangular shade cast by the bottom face. Both animate with skewX on :checked to match the rocking motion.
/* the housing groove — three layers in one declaration */
.toggle {
width: 3.75em;
height: 8.75em;
background: var(--surface);
border-radius: 0.375em;
box-shadow:
0 4.375em 2em hsl(var(--primary-hue), 10%, 70%) inset, /* dark well */
0 0.125em 0 var(--surface) inset, /* rim highlight */
0 0 0.375em hsla(var(--primary-hue), 10%, 10%, 0.5); /* outer lift */
}
/* cast shadow — absolute element, overflow hidden, split into two halves */
.toggle__shadow {
position: absolute;
top: 0.5em;
right: 0;
width: 5em;
height: calc(100% - 0.25em);
border-radius: 0.25em;
overflow: hidden; /* clips the pseudo-elements to a clean edge */
}
/* upper shadow half — triangular, rotates on :checked */
.toggle__shadow::before {
content: "";
position: absolute;
left: 1.625em;
top: 0;
width: 3em;
height: 50%;
background: hsla(var(--primary-hue), 10%, 10%, 0.15);
border-radius: 1.5em 0 0 0 / 1em 0 0 0;
transform-origin: 0 100%; /* rotates from its bottom-left corner */
/* default: flat */
}
/* lower shadow half — rectangular, skews on :checked */
.toggle__shadow::after {
content: "";
position: absolute;
left: 1.625em;
bottom: 0;
width: 3em;
height: 50%;
background: hsla(var(--primary-hue), 10%, 10%, 0.15);
border-radius: 0.25em;
transform-origin: 0 0;
transform: skewX(-10deg); /* default: angled */
}
/* ON state — shadows shift to match the rocked knob */
.toggle__input:checked ~ .toggle__shadow::before { transform: rotate(-10deg); }
.toggle__input:checked ~ .toggle__shadow::after { transform: skewX(0) scaleY(0.85); }Step 7 — The Glowing LED Bar
Each face contains a .toggle__indicator child. On the top face it is a 1em circle styled entirely with inset box-shadows to look like a recessed dome — dark ring, a downward highlight, and a subtle outer lift. On the bottom face it is a narrow 0.25 × 1.125em bar. In the OFF state the bar is a flat grey. When :checked, two things happen simultaneously: the background switches to --accent-strong (bright green) and a box-shadow with a large blur radius and matching hue creates a soft glow halo around the bar. Toggling the glow box-shadow between opacity 0 and 1 via the hsla alpha channel avoids any layout reflow.
/* top face indicator — circular recessed dome */
.toggle__face--top .toggle__indicator {
width: 1em;
height: 1em;
border-radius: 50%;
box-shadow:
0 0 0.125em hsl(var(--primary-hue), 10%, 65%) inset, /* ring */
0 0.25em 0 hsl(var(--primary-hue), 10%, 90%) inset, /* downward sheen */
0 0.125em 0 hsl(var(--primary-hue), 10%, 90%); /* outer lift */
}
/* bottom face indicator — LED bar, unlit by default */
.toggle__face--bottom .toggle__indicator {
width: 0.25em;
height: 1.125em;
background: hsl(var(--secondary-hue), 10%, 45%); /* dull grey-green */
box-shadow:
0 0.125em 0 hsl(var(--secondary-hue), 10%, 30%) inset,
0 -0.0625em 0 hsl(var(--primary-hue), 10%, 90%) inset,
0 0 0.5em hsla(var(--secondary-hue), 90%, 45%, 0); /* glow OFF */
}
/* LED lit — :checked state */
.toggle__input:checked ~ .toggle__knob
.toggle__face--bottom .toggle__indicator {
background: var(--accent-strong); /* bright green */
box-shadow:
0 0.125em 0 hsl(var(--secondary-hue), 90%, 30%) inset,
0 -0.0625em 0 hsl(var(--primary-hue), 10%, 90%) inset,
0 0 0.5em hsla(var(--secondary-hue), 90%, 45%, 1); /* glow ON */
}
.toggle__indicator {
display: block;
transition:
background var(--transition-duration) var(--transition-ease),
box-shadow var(--transition-duration) var(--transition-ease);
}Step 8 — Automatic Dark Mode via prefers-color-scheme
The light-mode palette uses hsl lightness values in the 70–90% range for the housing, knob, and faces — they read as light grey plastic. The dark-mode block inside @media (prefers-color-scheme: dark) overrides the same properties to lightness values in the 25–40% range, shifting the whole component to a dark charcoal housing without touching any structural CSS. The LED glow and accent colour remain identical in both themes — bright green is readable on dark and light alike.
@media (prefers-color-scheme: dark) {
:root {
--surface: var(--surface-dark); /* page background goes dark */
}
.toggle {
background: hsl(var(--primary-hue), 10%, 40%);
box-shadow:
0 4.375em 2em hsl(var(--primary-hue), 10%, 30%) inset, /* darker well */
0 0.125em 0 hsl(var(--primary-hue), 10%, 40%) inset,
0 0 0.375em hsla(var(--primary-hue), 10%, 10%, 0.5);
}
.toggle__knob {
background: hsla(var(--primary-hue), 10%, 30%, 1); /* dark charcoal */
}
.toggle__face--top { background: hsl(var(--primary-hue), 10%, 35%); }
.toggle__face--bottom { background: hsl(var(--primary-hue), 10%, 40%); }
/* OFF indicator bar — darker grey-green in dark mode */
.toggle__face--bottom .toggle__indicator {
background: hsl(var(--secondary-hue), 10%, 25%);
}
/* :checked states in dark mode — same lightness shifts, darker base */
.toggle__input:checked ~ .toggle__knob .toggle__face--top {
background: hsl(var(--primary-hue), 10%, 25%);
}
.toggle__input:checked ~ .toggle__knob .toggle__face--bottom {
background: hsl(var(--primary-hue), 10%, 35%);
}
}The hue custom properties (--primary-hue, --secondary-hue) are shared across both themes, which means every hsl() call automatically uses the correct hue regardless of theme. Only the lightness values need to change between light and dark — so those are the only properties overridden in the dark media query. This keeps the dark-mode block short and focused on what actually changes.
Tuning Reference
| Token / Property | Default | Effect |
|---|---|---|
| --primary-hue | 223 | Hue of the housing and knob. 0 = red, 120 = green, 220 = blue-grey |
| --secondary-hue | 133 | Hue of the LED indicator and accent glow |
| --transition-duration | 0.3s | Rock animation speed. Lower for a snappier switch, higher for a sluggish one |
| --transition-ease | cubic-bezier(0.83,0,0.17,1) | Easing curve for all transitions. Swap to ease-in-out for a softer feel |
| rotateX(35deg) | 35deg | Tilt angle of the pressed face. Higher = more dramatic rock, lower = subtle |
| transform: skewX(-10deg) on shadow::after | -10deg | Angle of the lower cast shadow. Match to your rotateX angle for realism |
| box-shadow glow alpha | 0 → 1 | LED glow intensity. Change the hsla alpha from 1 to 0.5 for a dimmer glow |
| width: 3.75em / height: 8.75em | — | Overall switch proportions. The 1:2.33 ratio reads as a standard rocker switch |
| margin: 0.375em on .toggle__knob | 0.375em | Housing groove width. Increase for a deeper channel around the knob |
| --shadow-blur on shadow disc | — | No explicit blur here — use filter: blur() on .toggle__shadow for a softer shadow |
Full Source Code
Save the following as rocker-switch.html and open in any modern browser. Zero dependencies, zero build step, zero JavaScript.
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/rocker-switch.htmlEvery visual detail in RockerToggle — the housing groove, the perspective tilt, the cast shadows, the LED glow — is pure CSS. The only structural element is a native checkbox. Not a single line of JavaScript touches the component.