Aduok Code

Realistic Rocker Switch with Pure HTML & CSS

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.

What you will learn

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".

01
Part One
Design Tokens

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.

CSS
: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 */
}
02
Part Two
HTML Structure

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.

HTML
<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>
ElementRoleAnimated
.toggleHousing — fixed 3.75 × 8.75em, layered box-shadow grooveNo
.toggle__inputHidden checkbox — the state machine for the whole componentNo
.toggle__shadowCast shadow disc to the right of the knob, split into two halvesYes — skewX + rotate on :checked
.toggle__knobInner plastic block — 6px inset on all sides inside the housingNo
.toggle__face--topUpper rocker face — tilts backward (rotateX 35deg) when ONYes — rotateX + background
.toggle__face--bottomLower rocker face — tilts backward (rotateX -35deg) when OFFYes — rotateX + background
.toggle__indicator (top)Circular indent — static depression styled with inset box-shadowYes — shadow tint shifts
.toggle__indicator (bot)LED bar — glows green with box-shadow when :checkedYes — background + box-shadow glow
03
Part Three
The Checkbox Hack

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.

CSS
.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 { /* ... */ }
04
Part Four
The Knob & Faces

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.

CSS
.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 */
}
05
Part Five
The rotateX Tilt Effect

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.

CSS
/* ── 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);
}
Why change the background colour on tilt?

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.

06
Part Six
Layered Shadows & Depth

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.

CSS
/* 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); }
07
Part Seven
The LED Indicator

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.

CSS
/* 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);
}
08
Part Eight
Dark Mode

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.

CSS
@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%);
  }
}
Why override properties instead of using CSS variables for everything?

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 / PropertyDefaultEffect
--primary-hue223Hue of the housing and knob. 0 = red, 120 = green, 220 = blue-grey
--secondary-hue133Hue of the LED indicator and accent glow
--transition-duration0.3sRock animation speed. Lower for a snappier switch, higher for a sluggish one
--transition-easecubic-bezier(0.83,0,0.17,1)Easing curve for all transitions. Swap to ease-in-out for a softer feel
rotateX(35deg)35degTilt angle of the pressed face. Higher = more dramatic rock, lower = subtle
transform: skewX(-10deg) on shadow::after-10degAngle of the lower cast shadow. Match to your rotateX angle for realism
box-shadow glow alpha0 → 1LED glow intensity. Change the hsla alpha from 1 to 0.5 for a dimmer glow
width: 3.75em / height: 8.75emOverall switch proportions. The 1:2.33 ratio reads as a standard rocker switch
margin: 0.375em on .toggle__knob0.375emHousing groove width. Increase for a deeper channel around the knob
--shadow-blur on shadow discNo 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.

HTML — rocker-switch.html (complete)
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/rocker-switch.html

Every 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.

Want to see it in action?Watch the full build on YouTube