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

Prism tabs in pure CSS

The tab panels are the four long sides of a prism. Radio inputs and labels pick a tab; each one sets a step count and the prism turns to that face.

  • 112 lines of CSS
  • 20 lines of HTML
  • No JavaScript
  • No dependencies
  • MIT licensed

How it works

  1. Four real radios come before the content in the markup, invisible but focusable, so the ~ general sibling combinator can reach both the nav and the prism from any of them.
  2. Each tab panel is a face of a prism, placed with the same "turn backwards, then push out" recipe as a cube: face n gets rotateX(n * -90deg) translateZ(46px).
  3. The prism is pulled back by half its own depth (translateZ(-46px)) so the panel currently in front sits exactly at z = 0 and its text stays sharp.
  4. Checking radio n sets --step to n - 1 on the prism; rotateX(calc(var(--step) * 90deg)) turns to that face, and one shared transition animates every possible jump.
  5. backface-visibility: hidden on the panels stops the back faces from showing through as the prism turns.

Key techniques

  • radio:checked ~ sibling
  • --step × 90deg
  • translateZ(-h / 2) keeps the front at z = 0
  • backface-visibility: hidden

Copy-paste code

HTML 20 lines

<div class="tabs">
  <input type="radio" name="tabs" id="tab-0" aria-label="Front" checked />
  <input type="radio" name="tabs" id="tab-1" aria-label="Bottom" />
  <input type="radio" name="tabs" id="tab-2" aria-label="Back" />
  <input type="radio" name="tabs" id="tab-3" aria-label="Top" />
  <div class="tabs-nav">
    <label for="tab-0">Front</label>
    <label for="tab-1">Bottom</label>
    <label for="tab-2">Back</label>
    <label for="tab-3">Top</label>
  </div>
  <div class="tabs-view">
    <div class="tabs-prism">
      <section style="--i:0;--c:#8b6cff"><b>Front</b><p>The face you start on. It sits at z = 0, so its text stays sharp.</p></section>
      <section style="--i:1;--c:#2ee6d6"><b>Bottom</b><p>A quarter turn forward brings the bottom face up to the front.</p></section>
      <section style="--i:2;--c:#ff4d9d"><b>Back</b><p>Half a turn. Placed with a half turn too, so it reads upright.</p></section>
      <section style="--i:3;--c:#ffb547"><b>Top</b><p>Three quarter turns, all driven by one custom property.</p></section>
    </div>
  </div>
</div>

CSS 112 lines

.tabs {
  position: relative;
  display: grid;
  gap: 20px;
  width: 204px;
}

/* real radios, invisible but focusable; they come first so ~ can reach everything else */
.tabs input {
  position: absolute;
  top: 0;
  left: 0;
  width: 1px;
  height: 1px;
  margin: 0;
  opacity: 0;
  pointer-events: none;
}

.tabs-nav {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 3px;
  padding: 3px;
  border: 1px solid #262b4a;
  border-radius: 10px;
  background: rgb(22 26 51 / 0.6);
}

.tabs-nav label {
  padding: 5px 0;
  border-radius: 7px;
  color: #949bc0;
  font-size: 11px;
  font-weight: 700;
  text-align: center;
  cursor: pointer;
  transition: background 0.25s, color 0.25s;
}

.tabs-nav label:hover {
  color: #eceefb;
}

/* the window the prism is seen through: static, and NOT clipped (clipping would flatten it) */
.tabs-view {
  height: 92px;
  perspective: 520px;
  pointer-events: none;
}

/* pulled back by half its depth so the face in front sits exactly at z = 0 */
.tabs-prism {
  position: relative;
  height: 100%;
  transform-style: preserve-3d;
  transform: translateZ(-46px) rotateX(calc(var(--step, 0) * 90deg));
  transition: transform 0.75s cubic-bezier(0.3, 1.2, 0.4, 1);
}

/* face n: n quarter turns backward, then out by half the depth -> front, bottom, back, top */
.tabs-prism section {
  position: absolute;
  inset: 0;
  display: grid;
  align-content: center;
  gap: 4px;
  padding: 0 16px;
  border: 1px solid color-mix(in srgb, var(--c) 70%, transparent);
  border-radius: 10px;
  background:
    linear-gradient(140deg, color-mix(in srgb, var(--c) 34%, transparent), transparent 70%),
    rgb(11 13 24 / 0.88);
  backface-visibility: hidden;
  transform: rotateX(calc(var(--i) * -90deg)) translateZ(46px);
}

.tabs-prism section b {
  color: var(--c);
  font-size: 15px;
  font-weight: 800;
}

.tabs-prism section p {
  margin: 0;
  color: #949bc0;
  font-size: 11.5px;
  line-height: 1.4;
}

/* one rule per tab: the nth radio turns the prism n-1 steps */
.tabs input:nth-of-type(1):checked ~ .tabs-view .tabs-prism { --step: 0; }
.tabs input:nth-of-type(2):checked ~ .tabs-view .tabs-prism { --step: 1; }
.tabs input:nth-of-type(3):checked ~ .tabs-view .tabs-prism { --step: 2; }
.tabs input:nth-of-type(4):checked ~ .tabs-view .tabs-prism { --step: 3; }

/* ...and lights the matching label */
.tabs input:nth-of-type(1):checked ~ .tabs-nav label:nth-of-type(1),
.tabs input:nth-of-type(2):checked ~ .tabs-nav label:nth-of-type(2),
.tabs input:nth-of-type(3):checked ~ .tabs-nav label:nth-of-type(3),
.tabs input:nth-of-type(4):checked ~ .tabs-nav label:nth-of-type(4) {
  background: #8b6cff;
  color: #fff;
}

.tabs input:nth-of-type(1):focus-visible ~ .tabs-nav label:nth-of-type(1),
.tabs input:nth-of-type(2):focus-visible ~ .tabs-nav label:nth-of-type(2),
.tabs input:nth-of-type(3):focus-visible ~ .tabs-nav label:nth-of-type(3),
.tabs input:nth-of-type(4):focus-visible ~ .tabs-nav label:nth-of-type(4) {
  outline: 2px solid #2ee6d6;
  outline-offset: 2px;
}

Sass source 111 lines

$w: 204px;
$h: 92px; // face height = prism depth (square cross-section)

.d-tabs {
  position: relative;
  display: grid;
  gap: 20px;
  width: $w;

  // Real radios, invisible but focusable. They come first so `~` can reach everything else.
  input {
    position: absolute;
    top: 0;
    left: 0;
    width: 1px;
    height: 1px;
    margin: 0;
    opacity: 0;
    pointer-events: none;
  }

  &__nav {
    display: grid;
    grid-template-columns: repeat(4, 1fr);
    gap: 3px;
    padding: 3px;
    border: 1px solid var(--border);
    border-radius: 10px;
    background: color-mix(in srgb, var(--surface-solid) 60%, transparent);

    label {
      padding: 5px 0;
      border-radius: 7px;
      color: var(--muted);
      font-size: 11px;
      font-weight: 700;
      text-align: center;
      cursor: pointer;
      transition:
        background 0.25s,
        color 0.25s;

      &:hover {
        color: var(--text);
      }
    }
  }

  // The window the prism is seen through. Static, and not clipped (clipping would flatten).
  &__view {
    height: $h;
    perspective: 520px;
    pointer-events: none;
  }

  // Pulled back by half its depth so the face in front sits exactly at z = 0.
  &__prism {
    position: relative;
    height: 100%;
    transform-style: preserve-3d;
    transform: translateZ($h * -0.5) rotateX(calc(var(--step, 0) * 90deg));
    transition: transform 0.75s cubic-bezier(0.3, 1.2, 0.4, 1);

    // Face n: n quarter turns backwards, then out by half the depth -> front, bottom, back, top.
    section {
      position: absolute;
      inset: 0;
      display: grid;
      align-content: center;
      gap: 4px;
      padding: 0 16px;
      border: 1px solid color-mix(in srgb, var(--c) 70%, transparent);
      border-radius: 10px;
      background:
        linear-gradient(140deg, color-mix(in srgb, var(--c) 34%, transparent), transparent 70%),
        color-mix(in srgb, var(--surface-solid) 88%, transparent);
      backface-visibility: hidden;
      transform: rotateX(calc(var(--i) * -90deg)) translateZ($h * 0.5);

      b {
        color: var(--c);
        font-size: 15px;
        font-weight: 800;
      }

      p {
        margin: 0;
        color: var(--muted);
        font-size: 11.5px;
        line-height: 1.4;
      }
    }
  }

  // One rule per tab: the nth radio turns the prism n-1 steps and lights the nth label.
  @for $n from 1 through 4 {
    input:nth-of-type(#{$n}):checked ~ &__view &__prism {
      --step: #{$n - 1};
    }

    input:nth-of-type(#{$n}):checked ~ &__nav label:nth-of-type(#{$n}) {
      background: var(--accent);
      color: #fff;
    }

    input:nth-of-type(#{$n}):focus-visible ~ &__nav label:nth-of-type(#{$n}) {
      outline: 2px solid var(--accent-2);
      outline-offset: 2px;
    }
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.