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

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.

  • 131 lines of CSS
  • 20 lines of HTML
  • No JavaScript
  • No dependencies
  • Snippet under MIT
GitHub

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(46 units).
  3. The prism is pulled back by half its own depth (translateZ(-46 units)) 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.
  6. Every length is a multiple of one base unit, --u, tied to the canvas, text included, so the tabs are the same share of a gallery card, the editor and a recording canvas. The tabs are the model, so there is no separate control row: the whole thing fills the frame.

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 131 lines

.tabs {
  /* one base unit: every length below is a multiple of it, so the tabs are the same share of a
     card, the editor, a full screen and a recording canvas */
  --u: 0.42vmin;
  position: relative;
  display: grid;
  gap: calc(20 * var(--u));
  width: calc(204 * var(--u));
}

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

.tabs-nav {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: calc(3 * var(--u));
  padding: calc(3 * var(--u));
  border: calc(1 * var(--u)) solid #262b4a;
  border-radius: calc(10 * var(--u));
  /* opaque, so the tab names read on either stage: see-through, on the light stage the bar was mid
     grey and the names on it 1.8:1 */
  background: #11142a;
}

.tabs-nav label {
  padding: calc(5 * var(--u)) 0;
  border-radius: calc(7 * var(--u));
  color: #949bc0;
  font-size: calc(11 * var(--u));
  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: calc(92 * var(--u));
  perspective: calc(520 * var(--u));
  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(calc(-46 * var(--u))) 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: calc(4 * var(--u));
  padding: 0 calc(17 * var(--u));
  /* an inner line plus a soft shade instead of a hard border: turning, a face passes
     side-on, where a 1 units line breaks up; the shade survives */
  box-shadow:
    inset 0 0 0 calc(1 * var(--u)) color-mix(in srgb, var(--c) 70%, transparent),
    inset 0 0 calc(8 * var(--u)) color-mix(in srgb, var(--c) 28%, transparent);
  border-radius: calc(10 * var(--u));
  background:
    linear-gradient(140deg, color-mix(in srgb, var(--c) 34%, transparent), transparent 70%),
    rgb(11 13 24); /* opaque: its words read the same on either stage */
  backface-visibility: hidden;
  transform: rotateX(calc(var(--i) * -90deg)) translateZ(calc(46 * var(--u)));
}

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

.tabs-prism section p {
  margin: 0;
  color: #949bc0;
  font-size: calc(11.5 * var(--u));
  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: #6a45f5; /* deep enough for white words, 5.9:1: #8b6cff was 3.7:1 */
  color: #fff;
}

/* and it answers the pointer: this selector far outweighs the block's label:hover, so the tab you
   are already on was the one tab that did nothing. Darker again -- #5537c4 takes white further
   past the 5.9:1 above, never back towards it. */
.tabs input:nth-of-type(1):checked ~ .tabs-nav label:nth-of-type(1):hover,
.tabs input:nth-of-type(2):checked ~ .tabs-nav label:nth-of-type(2):hover,
.tabs input:nth-of-type(3):checked ~ .tabs-nav label:nth-of-type(3):hover,
.tabs input:nth-of-type(4):checked ~ .tabs-nav label:nth-of-type(4):hover {
  background: #5537c4;
}

.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: calc(2 * var(--u)) solid #2ee6d6;
  outline-offset: calc(2 * var(--u));
}
Standalone snippet · plain CSS, no build step, no dependencies.