Pure CSS Shapes & solids #form-hack #controls

Radio-button cube in pure CSS

Interactive with no JavaScript: checked radios steer the cube through sibling selectors.

  • 68 lines of CSS
  • 15 lines of HTML
  • No JavaScript
  • No dependencies
  • MIT licensed
GitHub

How it works

  1. Real <input type="radio"> elements hold the state — the browser handles clicks and arrow keys.
  2. The cube comes after the inputs in the markup, so input:checked ~ .cube can reach it.
  3. Each radio maps to the rotation that brings its face to the front; a transition animates between them.
  4. appearance: none lets you restyle the radios as buttons. Keep the aria-labels.

Key techniques

  • :checked ~ sibling selector
  • transition on transform
  • appearance: none

Copy-paste code

HTML 15 lines

<div class="picker">
  <div class="controls-and-cube">
    <input type="radio" name="face" aria-label="Front" checked>
    <input type="radio" name="face" aria-label="Right">
    <input type="radio" name="face" aria-label="Back">
    <input type="radio" name="face" aria-label="Left">
    <input type="radio" name="face" aria-label="Top">
    <input type="radio" name="face" aria-label="Bottom">

    <div class="cube">
      <div>Front</div><div>Right</div><div>Back</div>
      <div>Left</div><div>Top</div><div>Bottom</div>
    </div>
  </div>
</div>

CSS 68 lines

.picker {
  perspective: 800px;
}

.controls-and-cube {
  display: grid;
  grid-template-columns: repeat(6, auto);
  justify-content: center;
  gap: 40px 8px;
  transform-style: preserve-3d;
}

input {
  appearance: none;
  width: 26px;
  height: 26px;
  margin: 0;
  border: 2px solid #5a6188;
  border-radius: 8px;
  cursor: pointer;
}

input:checked {
  background: #2ee6d6;
  border-color: #2ee6d6;
  box-shadow: 0 0 12px #2ee6d6;
}

.cube {
  --s: 120px;
  --view: rotateX(-18deg) rotateY(-22deg);
  grid-row: 1;
  grid-column: 1 / -1;
  justify-self: center;
  position: relative;
  width: var(--s);
  height: var(--s);
  margin-top: 30px;
  transform-style: preserve-3d;
  transform: var(--view);
  transition: transform 0.9s cubic-bezier(0.3, 1.3, 0.5, 1);
}

/* the whole trick: a checked radio restyles a LATER sibling */
input:nth-of-type(1):checked ~ .cube { transform: var(--view) rotateY(0deg); }
input:nth-of-type(2):checked ~ .cube { transform: var(--view) rotateY(-90deg); }
input:nth-of-type(3):checked ~ .cube { transform: var(--view) rotateY(-180deg); }
input:nth-of-type(4):checked ~ .cube { transform: var(--view) rotateY(90deg); }
input:nth-of-type(5):checked ~ .cube { transform: var(--view) rotateX(-90deg); }
input:nth-of-type(6):checked ~ .cube { transform: var(--view) rotateX(90deg); }

.cube > * {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  font: 700 1rem system-ui;
  background: rgb(46 230 214 / 0.25);
  border: 1px solid rgb(46 230 214 / 0.8);
}

/* 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)); }

Sass source 67 lines

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

// Rotation that brings each face to the front: Front, Right, Back, Left, Top, Bottom.
$views: (rotateY(0deg), rotateY(-90deg), rotateY(-180deg), rotateY(90deg), rotateX(-90deg), rotateX(90deg));

.d-radio {
  display: grid;
  grid-template-columns: repeat(6, auto);
  justify-content: center;
  gap: 18px 6px;
  transform-style: preserve-3d;

  input {
    appearance: none;
    width: 22px;
    height: 22px;
    margin: 0;
    border: 2px solid var(--border-strong);
    border-radius: 7px;
    cursor: pointer;
    transition: background 0.2s;

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

    &:checked {
      background: var(--accent-2);
      border-color: var(--accent-2);
      box-shadow: 0 0 12px var(--accent-2);
    }
  }

  &__cube {
    --s: 92px;
    grid-row: 1;
    grid-column: 1 / -1;
    justify-self: center;
    position: relative;
    width: var(--s);
    height: var(--s);
    margin: 14px 0 8px;
    transform-style: preserve-3d;
    transform: rotateX(-18deg) rotateY(-22deg);
    transition: transform 0.9s cubic-bezier(0.3, 1.3, 0.5, 1);
    @include cube-faces(var(--s));

    i {
      position: absolute;
      inset: 0;
      display: grid;
      place-items: center;
      font-size: 13px;
      font-style: normal;
      font-weight: 700;
      @include face(var(--accent-2));
    }
  }

  // The whole trick: a checked radio restyles a LATER sibling. No script involved.
  @for $i from 1 through 6 {
    input:nth-of-type(#{$i}):checked ~ &__cube {
      transform: rotateX(-18deg) rotateY(-22deg) #{list.nth($views, $i)};
    }
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.