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.

  • 133 lines of CSS
  • 29 lines of HTML
  • No JavaScript
  • No dependencies
  • MIT licensed

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. 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: 160px stage so all three read from the same angle.
  3. The 6th face is the cube’s bottom: it carries the tick and starts out of sight, tucked underneath.
  4. 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.
  5. 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 133 lines

.check-list {
  display: grid;
  gap: 7px;
  width: 204px;
  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: 12px;
  padding: 8px 12px;
  border: 1px solid #262b4a;
  border-radius: 12px;
  background: rgb(22 26 51 / 0.6);
  color: #eceefb;
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
  transition: border-color 0.2s;
}

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

.check-list input:focus-visible + label {
  outline: 2px solid #2ee6d6;
  outline-offset: 2px;
}

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

.cube {
  position: relative;
  display: block;
  width: 22px;
  height: 22px;
  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(11px); }
.cube i:nth-child(2) { transform: rotateY(90deg)  translateZ(11px); }
.cube i:nth-child(3) { transform: rotateY(180deg) translateZ(11px); }
.cube i:nth-child(4) { transform: rotateY(-90deg) translateZ(11px); }
.cube i:nth-child(5) { transform: rotateX(90deg)  translateZ(11px); }
.cube i:nth-child(6) { transform: rotateX(-90deg) translateZ(11px); }

.cube i {
  position: absolute;
  inset: 0;
  border-radius: 3px;
  background: rgb(139 108 255 / 0.22);
  border: 1px solid rgb(139 108 255 / 0.75);
  box-shadow: inset 0 0 12px 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;
  border-color: #2ee6d6;
  background: #2ee6d6;
  box-shadow: 0 0 12px 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: 15px;
  height: 15px;
}

/* 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: -3px;
  left: -3px;
  height: 1.5px;
  border-radius: 1px;
  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;
}

Sass source 129 lines

@use '../mixins' as *;

$s: 22px;

.d-check {
  display: grid;
  gap: 7px;
  width: 204px;
  margin: 0;
  padding: 0;
  list-style: none;

  li {
    position: relative;
  }

  // The real checkbox stays in the page (keyboard, screen readers, forms); it is only invisible.
  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.
  label {
    display: flex;
    align-items: center;
    gap: 12px;
    padding: 8px 12px;
    border: 1px solid var(--border);
    border-radius: 12px;
    background: color-mix(in srgb, var(--surface-solid) 60%, transparent);
    color: var(--text);
    font-size: 13px;
    font-weight: 600;
    cursor: pointer;
    transition: border-color 0.2s;

    &:hover {
      border-color: var(--border-strong);
    }
  }

  input:focus-visible + label {
    outline: 2px solid var(--accent-2);
    outline-offset: 2px;
  }

  // A tiny stage of its own for every box, so all three are seen from the same angle.
  &__box {
    flex: none;
    width: $s;
    height: $s;
    perspective: 160px;
    pointer-events: none;
  }

  &__cube {
    --s: #{$s};
    position: relative;
    display: block;
    width: $s;
    height: $s;
    transform-style: preserve-3d;
    // View angle first, then the flip: 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);
    @include cube-faces(var(--s));

    i {
      position: absolute;
      inset: 0;
      border-radius: 3px;
      @include face(var(--accent), 22%);
    }

    // The 6th face is the BOTTOM of the cube: it carries the tick and is out of sight at rest.
    i:last-child {
      display: grid;
      place-items: center;
      border-color: var(--accent-2);
      background: var(--accent-2);
      box-shadow: 0 0 12px color-mix(in srgb, var(--accent-2) 60%, transparent);
      color: #062b28;
      backface-visibility: hidden; // looking down into the glass cube you would see its inside

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

  // A quarter turn forward brings the bottom face to the front.
  input:checked + label &__cube {
    transform: rotateX(-16deg) rotateY(-24deg) rotateX(90deg);
  }

  // The strike-through is a line scaled from 0 to 1: transform only, no layout, no repaint.
  &__text {
    position: relative;
    transition: opacity 0.3s;

    &::after {
      content: '';
      position: absolute;
      top: 52%;
      right: -3px;
      left: -3px;
      height: 1.5px;
      border-radius: 1px;
      background: var(--accent-2);
      transform: scaleX(0);
      transform-origin: 0 50%;
      transition: transform 0.35s ease-out;
    }
  }

  input:checked + label &__text {
    opacity: 0.55;

    &::after {
      transform: scaleX(1);
      transition-delay: 0.15s;
    }
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.