Pure CSS Buttons & forms #controls #form-hack #switch #shape

Rolling cube toggle in pure CSS

A chunky switch whose knob is a real cube. Checked, it tips over its right-hand edge onto the other half of the track, bringing the moon face up, and the track changes colour.

  • 218 lines of CSS
  • 12 lines of HTML
  • No JavaScript
  • No dependencies
  • Snippet under MIT
GitHub

How it works

  1. Every length is a multiple of one base unit, --u, tied to the canvas, so the track, the cube on it and the label under it are the same share of a gallery card, the editor and a recording canvas. The numbers below are those units.
  2. The track lies on the floor (rotateX(58deg)). Its thickness is five copies of the pill stacked 2.4 units apart downward, lighter at the top: stacked layers are the cheap way to extrude a rounded shape.
  3. The knob is a real cube on the track, lifted by half a side so it stands on it. Its transform-origin is its bottom right edge: 100% 50% -17 units (the third value is Z, from the cube’s centre).
  4. Checked, the cube gets rotateY(90deg) about that edge: exactly how a cube tips over. The centre swings up on an arc and lands one side further on, with no translate and no keyframes. The track is two cube widths long, so one tip is the whole travel.
  5. The moon is on the cube’s left face: a quarter turn to the right brings it up on top. The sun, on the old top, ends up facing right.
  6. Only transform and opacity animate: the track’s “on” colour is a layer that fades in, and Off/On are two words in one grid cell. The whole thing is one <label> around a real role="switch" checkbox.

Key techniques

  • transform-origin on the cube’s bottom edge
  • one rotateY(90deg) = a real roll
  • stacked layers for the track’s depth
  • colour swap by cross-fading opacity

Copy-paste code

HTML 12 lines

<label class="toggle">
  <input type="checkbox" role="switch" />
  <span class="track" aria-hidden="true">
    <i></i><i></i><i></i><i></i><i></i>
    <span class="top"></span>
    <i class="shadow"></i>
    <span class="knob">
      <span class="cube"><i><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.9 4.9 1.4 1.4"/><path d="m17.7 17.7 1.4 1.4"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.3 17.7-1.4 1.4"/><path d="m19.1 4.9-1.4 1.4"/></svg></i><i></i><i></i><i><svg viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2.4" stroke-linejoin="round" aria-hidden="true"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/></svg></i><i></i><i></i></span>
    </span>
  </span>
  <span class="text">Dark mode<small aria-hidden="true"><span>Off</span><span>On</span></small></span>
</label>

CSS 218 lines

/* one label: the static hit target around a real checkbox */
.toggle {
  /* one base unit: every length below is a multiple of it, so the toggle is the same share of a
     card, the editor, a full screen and a recording canvas */
  --u: 0.55vmin;
  /* the label stands under the track, not beside it: side by side the pair is two and a half
     times wider than tall, and at the 92vmin width limit the whole model falls under the floor */
  display: grid;
  justify-items: center;
  gap: calc(18 * var(--u));
  /* more room above than below: the cube stands up out of the track, so this is what centres
     the drawn stack, cube, track and caption together, rather than the label's box */
  padding: calc(25 * var(--u)) calc(10 * var(--u)) calc(11 * var(--u));
  /* no colour of its own: the label is written on the stage, so it takes the stage's ink */
  font-size: calc(15 * var(--u));
  font-weight: 800;
  cursor: pointer;
  perspective: calc(800 * var(--u));
  transform-style: preserve-3d;
  user-select: none;
}

.toggle * {
  pointer-events: none;
}

.toggle input {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: 0;
  opacity: 0;
}

/* 34-unit cube + 8 units of padding: two cube widths long, so one roll is the whole travel */
.track {
  position: relative;
  flex: none;
  width: calc(84 * var(--u));
  height: calc(50 * var(--u));
  transform-style: preserve-3d;
  transform: rotateX(58deg); /* lying on the floor, seen from above */
}

/* the thickness: copies of the pill stacked downward, darker as they go */
.track > i:not(.shadow) {
  position: absolute;
  inset: 0;
  border-radius: calc(25 * var(--u));
}

.track > i:nth-child(1) { background: color-mix(in srgb, #eceefb 15%, #101326); transform: translateZ(calc(-12 * var(--u))); }
.track > i:nth-child(2) { background: color-mix(in srgb, #eceefb 18%, #101326); transform: translateZ(calc(-9.6 * var(--u))); }
.track > i:nth-child(3) { background: color-mix(in srgb, #eceefb 21%, #101326); transform: translateZ(calc(-7.2 * var(--u))); }
.track > i:nth-child(4) { background: color-mix(in srgb, #eceefb 24%, #101326); transform: translateZ(calc(-4.8 * var(--u))); }
.track > i:nth-child(5) { background: color-mix(in srgb, #eceefb 27%, #101326); transform: translateZ(calc(-2.4 * var(--u))); }

/* the top: a groove. "On" is a second layer that only fades in */
.top {
  position: absolute;
  inset: 0;
  border-radius: calc(25 * var(--u));
  background: color-mix(in srgb, #949bc0 30%, #141830);
  box-shadow: inset 0 0 0 calc(1.5 * var(--u)) color-mix(in srgb, #eceefb 30%, #141830), inset 0 calc(3 * var(--u)) calc(8 * var(--u)) rgb(0 0 0 / 0.35);
}

.top::before {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: linear-gradient(90deg, #8b6cff, #ff4d9d);
  box-shadow: inset 0 0 0 calc(1.5 * var(--u)) color-mix(in srgb, #8b6cff, #fff 35%), inset 0 calc(3 * var(--u)) calc(8 * var(--u)) rgb(0 0 0 / 0.35);
  opacity: 0;
  transition: opacity 0.4s 0.1s;
}

.toggle input:checked ~ .track .top::before {
  opacity: 1;
}

.toggle input:focus-visible ~ .track .top {
  outline: calc(2 * var(--u)) solid #2ee6d6;
  outline-offset: calc(4 * var(--u));
}

/* the cube's shadow, sliding along with it */
.shadow {
  position: absolute;
  top: calc(8 * var(--u));
  left: calc(8 * var(--u));
  width: calc(34 * var(--u));
  height: calc(34 * var(--u));
  border-radius: 50%;
  background: radial-gradient(closest-side, rgb(0 0 0 / 0.5), transparent);
  transform: translateZ(calc(0.5 * var(--u))) scale(1.25);
  transition: transform 0.5s cubic-bezier(0.55, 0, 0.35, 1);
}

.toggle input:checked ~ .track .shadow {
  transform: translateZ(calc(0.5 * var(--u))) translateX(calc(34 * var(--u))) scale(1.25);
}

/* lifted by half a side, so the cube stands on the track */
.knob {
  position: absolute;
  top: calc(8 * var(--u));
  left: calc(8 * var(--u));
  width: calc(34 * var(--u));
  height: calc(34 * var(--u));
  transform-style: preserve-3d;
  transform: translateZ(calc(17 * var(--u)));
}

/* the pivot is the cube's bottom right edge: x = 100%, z = -17 units from its centre */
.cube {
  --s: calc(34 * var(--u));
  position: absolute;
  inset: 0;
  transform-style: preserve-3d;
  transform-origin: 100% 50% calc(-17 * var(--u));
  transition: transform 0.5s cubic-bezier(0.55, 0, 0.35, 1);
}

/* checked: a quarter turn about that edge, a real roll */
.toggle input:checked ~ .track .cube {
  transform: rotateY(90deg);
}

/* in the track's own space +z is up: face 1 is the top (sun), 4 the left (moon) */
/* turn each face outward, then push it half a side from the centre */
.cube > :nth-child(1) { transform: rotateY(0deg)   translateZ(calc(var(--s) / 2)); }
.cube > :nth-child(2) { transform: rotateY(90deg)  translateZ(calc(var(--s) / 2)); }
.cube > :nth-child(3) { transform: rotateY(180deg) translateZ(calc(var(--s) / 2)); }
.cube > :nth-child(4) { transform: rotateY(-90deg) translateZ(calc(var(--s) / 2)); }
.cube > :nth-child(5) { transform: rotateX(90deg)  translateZ(calc(var(--s) / 2)); }
.cube > :nth-child(6) { transform: rotateX(-90deg) translateZ(calc(var(--s) / 2)); }

.cube > i {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  border-radius: calc(5 * var(--u));
  background: linear-gradient(145deg, #fff, #dcd9ef);
  transform-style: preserve-3d;
}

/* rounded faces leave holes at the corners: a square plate behind each one plugs them */
.cube > i::before {
  content: '';
  position: absolute;
  inset: 0; /* full size: a smaller plate leaves a channel along each edge you can see into */
  background: #c9c5e2;
  transform: translateZ(calc(-3 * var(--u)));
}

.cube > i:nth-child(2) { background: linear-gradient(145deg, #eceaf8, #cbc7e3); }
.cube > i:nth-child(6) { background: linear-gradient(#d9d5ee, #b9b4d6); }

/* the sun and the moon inside a rim of the cube's own colour (like a keycap): at a rounded
   corner a sliver of the side face shows, and a dark moon there looked like a hole */
.cube > i:nth-child(1) {
  background: radial-gradient(circle, #fff6df, #ffe3a6);
  box-shadow: inset 0 0 0 calc(2.5 * var(--u)) #dcd9ef;
  color: #e08a00;
}

.cube > i:nth-child(4) {
  background: radial-gradient(circle at 40% 35%, #34396e, #181b3c);
  box-shadow: inset 0 0 0 calc(2.5 * var(--u)) #d4d0ea;
  color: #e9e4ff;
}

.cube svg {
  width: calc(22 * var(--u));
  height: calc(22 * var(--u));
}

.cube > i:nth-child(4) svg {
  width: calc(19 * var(--u));
  height: calc(19 * var(--u));
}

/* the label and its state: two words in one grid cell, cross-faded */
.text {
  display: grid;
  justify-items: center;
  gap: calc(2 * var(--u));
  line-height: 1.1;
}

.text small {
  display: grid;
  font-size: calc(11 * var(--u));
  font-weight: 700;
  letter-spacing: 0.1em;
  text-transform: uppercase;
}

.text small span {
  grid-area: 1 / 1;
  transition: opacity 0.3s;
}

/* Off: the stage's ink, softened; On: violet leaning towards that ink, so both read on a dark
   and on a light stage */
.text small span:first-child {
  color: color-mix(in srgb, currentColor 65%, transparent);
}

.text small span + span {
  color: color-mix(in srgb, #8b6cff 60%, currentColor);
  opacity: 0;
}

.toggle input:checked ~ .text span:first-child { opacity: 0; }
.toggle input:checked ~ .text span + span { opacity: 1; }
Standalone snippet · plain CSS, no build step, no dependencies.