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.

  • 205 lines of CSS
  • 12 lines of HTML
  • No JavaScript
  • No dependencies
  • MIT licensed

How it works

  1. The track lies on the floor (rotateX(58deg)). Its thickness is five copies of the pill stacked 2.4px apart downward, lighter at the top: stacked layers are the cheap way to extrude a rounded shape.
  2. 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% -17px (the third value is Z, from the cube’s centre).
  3. 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.
  4. 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.
  5. 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 205 lines

/* one label: the static hit target around a real checkbox */
.toggle {
  display: flex;
  align-items: center;
  gap: 18px;
  padding: 18px 10px;
  color: #eceefb;
  font-size: 15px;
  font-weight: 800;
  cursor: pointer;
  perspective: 800px;
  transform-style: preserve-3d;
  user-select: none;
}

.toggle * {
  pointer-events: none;
}

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

/* 34px cube + 8px padding: two cube widths long, so one roll is the whole travel */
.track {
  position: relative;
  flex: none;
  width: 84px;
  height: 50px;
  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: 25px;
}

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

/* the top: a groove. "On" is a second layer that only fades in */
.top {
  position: absolute;
  inset: 0;
  border-radius: 25px;
  background: color-mix(in srgb, #949bc0 30%, #141830);
  box-shadow: inset 0 0 0 1.5px color-mix(in srgb, #eceefb 30%, #141830), inset 0 3px 8px 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 1.5px color-mix(in srgb, #8b6cff, #fff 35%), inset 0 3px 8px 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: 2px solid #2ee6d6;
  outline-offset: 4px;
}

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

.toggle input:checked ~ .track .shadow {
  transform: translateZ(0.5px) translateX(34px) scale(1.25);
}

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

/* the pivot is the cube's bottom right edge: x = 100%, z = -17px from its centre */
.cube {
  --s: 34px;
  position: absolute;
  inset: 0;
  transform-style: preserve-3d;
  transform-origin: 100% 50% -17px;
  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: 5px;
  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(-3px);
}

.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 2.5px #dcd9ef;
  color: #e08a00;
}

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

.cube svg {
  width: 22px;
  height: 22px;
}

.cube > i:nth-child(4) svg {
  width: 19px;
  height: 19px;
}

/* the label and its state: two words in one grid cell, cross-faded */
.text {
  display: grid;
  gap: 2px;
  line-height: 1.1;
}

.text small {
  display: grid;
  color: #949bc0;
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.1em;
  text-transform: uppercase;
}

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

.text small span + span {
  color: #8b6cff;
  opacity: 0;
}

.toggle input:checked ~ .text span:first-child { opacity: 0; }
.toggle input:checked ~ .text span + span { opacity: 1; }

Sass source 217 lines

@use 'sass:math';
@use '../mixins' as *;

$s: 34px; // the cube
$pad: 8px; // space around it inside the track
$w: $s * 2 + $pad * 2; // track: two cube widths, so one roll is exactly one side long
$h: $s + $pad * 2;
$depth: 5; // layers under the top, 2.4px apart

// The whole switch is one <label>: the static hit target around a real checkbox.
.d-toggle {
  display: flex;
  align-items: center;
  gap: 18px;
  padding: 18px 10px; // room for the cube, which stands up out of the tilted track
  color: var(--text);
  font-size: 15px;
  font-weight: 800;
  cursor: pointer;
  transform-style: preserve-3d;
  user-select: none;

  * {
    pointer-events: none;
  }

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

  &__track {
    position: relative;
    flex: none;
    width: $w;
    height: $h;
    transform-style: preserve-3d;
    // lying on the floor, seen from above
    transform: rotateX(58deg);

    // the housing's thickness: copies of the pill stacked downward, darker as they go
    > i:not(.d-toggle__shadow) {
      position: absolute;
      inset: 0;
      border-radius: math.div($h, 2);
    }

    @for $n from 1 through $depth {
      > i:nth-child(#{$n}) {
        background: color-mix(in srgb, var(--text) #{12% + $n * 3%}, color-mix(in srgb, var(--surface-solid) 80%, #000));
        transform: translateZ(-2.4px * ($depth + 1 - $n));
      }
    }
  }

  // The top: a groove the cube rolls in. Off and on colours are two layers; the switch only
  // fades the "on" one in, so nothing but opacity animates.
  &__top {
    position: absolute;
    inset: 0;
    border-radius: math.div($h, 2);
    background: color-mix(in srgb, var(--muted) 30%, var(--surface-solid));
    box-shadow:
      inset 0 0 0 1.5px color-mix(in srgb, var(--text) 30%, var(--surface-solid)),
      inset 0 3px 8px rgb(0 0 0 / 0.35);

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

  input:checked ~ &__track &__top::before {
    opacity: 1;
  }

  input:focus-visible ~ &__track &__top {
    outline: 2px solid var(--accent-2);
    outline-offset: 4px;
  }

  // the cube's shadow on the groove, sliding along with it
  &__shadow {
    position: absolute;
    top: $pad;
    left: $pad;
    width: $s;
    height: $s;
    border-radius: 50%;
    background: radial-gradient(closest-side, rgb(0 0 0 / 0.5), transparent);
    transform: translateZ(0.5px) scale(1.25);
    transition: transform 0.5s cubic-bezier(0.55, 0, 0.35, 1);
  }

  input:checked ~ &__track &__shadow {
    transform: translateZ(0.5px) translateX($s) scale(1.25);
  }

  // lifted by half a side, so the cube stands on the top instead of sinking through it
  &__knob {
    position: absolute;
    top: $pad;
    left: $pad;
    width: $s;
    height: $s;
    transform-style: preserve-3d;
    transform: translateZ($s * 0.5);
  }

  // The roll: the pivot is the cube's bottom right edge (x = 100%, z = −s/2 from its centre).
  // A quarter turn about that edge is exactly how a cube tips over: the centre swings up on an
  // arc and lands one side further on. No translate, no guessing the path.
  &__cube {
    --s: #{$s};
    position: absolute;
    inset: 0;
    transform-style: preserve-3d;
    transform-origin: 100% 50% ($s * -0.5);
    transition: transform 0.5s cubic-bezier(0.55, 0, 0.35, 1);
    @include cube-faces(var(--s));

    // faces in cube-faces order: top (+z, the sun), right, bottom, left (the moon, which ends on
    // top after the roll), back, front
    i {
      position: absolute;
      inset: 0;
      display: grid;
      place-items: center;
      border-radius: 5px;
      background: linear-gradient(145deg, #fff, #dcd9ef);
      @include solid-core(3px, #c9c5e2);

      svg {
        width: 22px;
        height: 22px;
      }
    }

    i:nth-child(2),
    i:nth-child(4) {
      background: linear-gradient(145deg, #eceaf8, #cbc7e3);
    }

    i:nth-child(6) {
      background: linear-gradient(#d9d5ee, #b9b4d6);
    }

    // The sun and the moon are painted inside a rim of the cube's own colour, like a keycap: at
    // a rounded corner you see a sliver of the side face next to it, and a dark moon face there
    // looked like a hole into the cube.
    i:nth-child(1) {
      background: radial-gradient(circle, #fff6df, #ffe3a6);
      box-shadow: inset 0 0 0 2.5px #dcd9ef;
      color: #e08a00;
    }

    i:nth-child(4) {
      background: radial-gradient(circle at 40% 35%, #34396e, #181b3c);
      box-shadow: inset 0 0 0 2.5px #d4d0ea;
      color: #e9e4ff;

      svg {
        width: 19px;
        height: 19px;
      }
    }
  }

  input:checked ~ &__track &__cube {
    transform: rotateY(90deg);
  }

  // the label and its state word, the two words cross-faded in one grid cell
  &__text {
    display: grid;
    gap: 2px;
    line-height: 1.1;

    small {
      display: grid;
      color: var(--muted);
      font-size: 11px;
      font-weight: 700;
      letter-spacing: 0.1em;
      text-transform: uppercase;
    }

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

    span + span {
      color: var(--accent);
      opacity: 0;
    }
  }

  input:checked ~ &__text span:first-child {
    opacity: 0;
  }

  input:checked ~ &__text span + span {
    opacity: 1;
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.