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

3D checkboxes in pure CSS

Each checkbox is a small glass cube with the tick on its bottom face. :checked rolls the cube a quarter turn forward; the inputs are real, so keyboard and forms just work.

  • 141 lines of CSS
  • 29 lines of HTML
  • No JavaScript
  • No dependencies
  • Snippet under MIT
GitHub

How it works

  1. The real <input type="checkbox"> stays in the page — keyboard, forms and screen readers all keep working — it is only made invisible with opacity: 0. The <label> next to it is the static hit target, since the pressed element must never be the one that moves.
  2. Every length is a multiple of one base unit, --u, tied to the canvas — the rows, the cubes, the tick and the strike-through — so the list is the same share of a gallery card, the editor and a recording canvas. The numbers below are those units.
  3. Each cube is placed with the same "turn, then push half a side out" recipe as a plain CSS cube, in its own tiny perspective: 160 units stage so all three read from the same angle.
  4. The 6th face is the cube’s bottom: it carries the tick and starts out of sight, tucked underneath.
  5. input:checked + label .cube adds one more rotateX(90deg) on top of the resting view angle — a quarter turn forward brings that bottom face round to the front.
  6. The strike-through line animates scaleX(0 → 1) from a fixed-width element instead of animating width, so it costs only a compositor transform.

Key techniques

  • input:checked + label
  • cube rolls rotateX(90deg)
  • static label = hit target
  • strike-through with scaleX

Copy-paste code

HTML 29 lines

<ul class="check-list">
  <li>
    <input type="checkbox" id="check-0" checked />
    <label for="check-0">
      <span class="box">
        <span class="cube"><i></i><i></i><i></i><i></i><i></i><i><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg></i></span>
      </span>
      <span class="text">Set the perspective</span>
    </label>
  </li>
  <li>
    <input type="checkbox" id="check-1" />
    <label for="check-1">
      <span class="box">
        <span class="cube"><i></i><i></i><i></i><i></i><i></i><i><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg></i></span>
      </span>
      <span class="text">Preserve the 3D</span>
    </label>
  </li>
  <li>
    <input type="checkbox" id="check-2" />
    <label for="check-2">
      <span class="box">
        <span class="cube"><i></i><i></i><i></i><i></i><i></i><i><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg></i></span>
      </span>
      <span class="text">Flip the cube</span>
    </label>
  </li>
</ul>

CSS 141 lines

.check-list {
  /* one base unit: every length below is a multiple of it, so the list is the same share of a
     card, the editor, a full screen and a recording canvas */
  --u: 0.37vmin;
  display: grid;
  gap: calc(7 * var(--u));
  /* the row is as wide as its longest line, so the width is what the band binds on. The cube is
     30 units and the row padding 12, not 22 and 8: at the old sizes the list came out 86 x 54
     vmin, a third short of the box it is allowed to fill */
  width: calc(220 * var(--u));
  margin: 0;
  padding: 0;
  list-style: none;
}

.check-list li {
  position: relative;
}

/* the real checkbox stays for keyboard/forms/screen readers, only made invisible */
.check-list input {
  position: absolute;
  inset: 0;
  margin: 0;
  opacity: 0;
  pointer-events: none;
}

/* the label is the static hit target: nothing the pointer can touch ever moves */
.check-list label {
  display: flex;
  align-items: center;
  gap: calc(12 * var(--u));
  padding: calc(12 * var(--u)) calc(14 * var(--u));
  border: calc(1 * var(--u)) solid #262b4a;
  border-radius: calc(12 * var(--u));
  /* opaque, so its light words read on either stage: see-through, on the light stage the card was
     mid grey and the words on it 2.4:1 */
  background: #11142a;
  color: #eceefb;
  font-size: calc(13 * var(--u));
  font-weight: 600;
  cursor: pointer;
  transition: border-color 0.2s;
}

.check-list label:hover {
  border-color: #3a4070;
}

.check-list input:focus-visible + label {
  outline: calc(2 * var(--u)) solid #2ee6d6;
  outline-offset: calc(2 * var(--u));
}

/* a tiny stage of its own for every box, so all three cubes are seen from the same angle */
.box {
  flex: none;
  width: calc(30 * var(--u));
  height: calc(30 * var(--u));
  perspective: calc(160 * var(--u));
  pointer-events: none;
}

.cube {
  position: relative;
  display: block;
  width: calc(30 * var(--u));
  height: calc(30 * var(--u));
  transform-style: preserve-3d;
  /* view angle first, then the flip happens around the cube's own X axis */
  transform: rotateX(-16deg) rotateY(-24deg) rotateX(0deg);
  transition: transform 0.55s cubic-bezier(0.3, 1.5, 0.5, 1);
}

/* turn each face outward, then push it half a side out from the centre */
.cube i:nth-child(1) { transform: rotateY(0deg)   translateZ(calc(15 * var(--u))); }
.cube i:nth-child(2) { transform: rotateY(90deg)  translateZ(calc(15 * var(--u))); }
.cube i:nth-child(3) { transform: rotateY(180deg) translateZ(calc(15 * var(--u))); }
.cube i:nth-child(4) { transform: rotateY(-90deg) translateZ(calc(15 * var(--u))); }
.cube i:nth-child(5) { transform: rotateX(90deg)  translateZ(calc(15 * var(--u))); }
.cube i:nth-child(6) { transform: rotateX(-90deg) translateZ(calc(15 * var(--u))); }

.cube i {
  position: absolute;
  inset: 0;
  border-radius: calc(4 * var(--u));
  background: rgb(139 108 255 / 0.22);
  /* the edge is an inner line, not a border: squeezed side-on, a 1-unit border breaks up; the
     glass's own inner glow is the soft shade that survives */
  box-shadow: inset 0 0 0 calc(1 * var(--u)) rgb(139 108 255 / 0.75), inset 0 0 calc(16 * var(--u)) rgb(139 108 255 / 0.3);
}

/* the 6th face is the BOTTOM of the cube: it carries the tick and is out of sight at rest */
.cube i:last-child {
  display: grid;
  place-items: center;
  background: #2ee6d6;
  box-shadow: 0 0 calc(16 * var(--u)) rgb(46 230 214 / 0.6);
  color: #062b28;
  backface-visibility: hidden; /* looking down into the glass cube you'd see the inside otherwise */
}

.cube i:last-child svg {
  width: calc(20 * var(--u));
  height: calc(20 * var(--u));
}

/* a quarter turn forward brings the bottom face to the front */
.check-list input:checked + label .cube {
  transform: rotateX(-16deg) rotateY(-24deg) rotateX(90deg);
}

.text {
  position: relative;
  transition: opacity 0.3s;
}

/* the strike-through is a line scaled from 0 to 1: transform only, no layout, no repaint */
.text::after {
  content: '';
  position: absolute;
  top: 52%;
  right: calc(-3 * var(--u));
  left: calc(-3 * var(--u));
  height: calc(1.5 * var(--u));
  border-radius: calc(1 * var(--u));
  background: #2ee6d6;
  transform: scaleX(0);
  transform-origin: 0 50%;
  transition: transform 0.35s ease-out;
}

.check-list input:checked + label .text {
  opacity: 0.55;
}

.check-list input:checked + label .text::after {
  transform: scaleX(1);
  transition-delay: 0.15s;
}
Standalone snippet · plain CSS, no build step, no dependencies.