CSS + JS Cards & galleries #controls #shape #cards #gallery

Cube gallery in CSS + JavaScript

A gallery on the four sides of a cube. JS keeps one ever-growing angle, so Prev, Next and the dots always turn the short way round and never unwind.

  • 213 lines of CSS
  • 27 lines of HTML
  • 31 lines of JS
  • No dependencies
  • Snippet under MIT
GitHub

How it works

  1. JS keeps one unbounded counter (…, -1, 0, 1, 2, … 7, 8, …), never a value clamped to 0-3. It writes it as one angle, index × -90deg, into --angle, and CSS turns the cube by it: rotateY(var(--angle)).
  2. Because the angle only ever grows or shrinks, Next always turns the same way — it never has to unwind back through 360° when it wraps from slide 4 to slide 1.
  3. For the dot buttons, JS picks the shortest path: ((target − current + 4) % 4) gives 0-3 steps forward; if that is more than half way around, subtracting 4 makes it a few steps backward instead.
  4. The cube is pulled back with translateZ(calc(-s / 2)) so whichever face is turned to the front sits exactly at z = 0 and renders at its true size, not shrunk by the perspective.
  5. Only transform is transitioned, so the turn runs on the compositor and stays smooth however fast you click.
  6. Every length in the cube is a multiple of one base unit, --u, tied to the canvas, so it is the same share of a gallery card, the editor and a recording canvas. The arrows, dots and caption are in plain vmin: the control zone is the same object, at the same size, in every model.

Key techniques

  • accumulated --angle (never reset to 0)
  • shortest path: ((target − current + 4) % 4), 3 → −1
  • translateZ(−s/2) keeps the front at z = 0
  • transition on transform

Copy-paste code

HTML 27 lines

<div class="cubenav">
  <div class="view">
    <div class="scene">
      <div class="cube">
        <i style="--hue:28">Dawn<small>01 / 04</small></i>
        <i style="--hue:178">Reef<small>02 / 04</small></i>
        <i style="--hue:300">Dusk<small>03 / 04</small></i>
        <i style="--hue:240">Night<small>04 / 04</small></i>
        <i></i>
        <i></i>
      </div>
    </div>
  </div>
  <div class="controls">
    <output class="caption" aria-live="polite">01 · Dawn</output>
    <div class="row">
      <button type="button" data-dir="-1" aria-label="Previous"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg></button>
      <span class="dots">
        <button type="button" data-go="0" aria-label="Go to Dawn"></button>
        <button type="button" data-go="1" aria-label="Go to Reef"></button>
        <button type="button" data-go="2" aria-label="Go to Dusk"></button>
        <button type="button" data-go="3" aria-label="Go to Night"></button>
      </span>
      <button type="button" data-dir="1" aria-label="Next"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg></button>
    </div>
  </div>
</div>

CSS 213 lines

.cubenav {
  /* one base unit: every length in the cube below is a multiple of it, so the carousel is the
     same share of a card, the editor, a full screen and a recording canvas. The control zone
     under it is in plain vmin, because it is the same object in every model. */
  --u: 0.25vmin;
  display: grid;
  justify-items: center;
  gap: 4vmin; /* the band's gap between the model and the control zone */
  font-family: Inter, system-ui, sans-serif;
}

/* the model box: the same height in every model that has controls, so the zone below it lands
   in the same place whatever the model is */
.view {
  display: grid;
  place-items: center;
  height: 44vmin;
}

.scene {
  position: relative;
  display: grid;
  place-items: center;
  /* the floor shadow hangs below the cube: the room for it keeps the cube centred */
  padding-bottom: calc(22 * var(--u));
  perspective: calc(700 * var(--u));
}

/* a soft shadow on the floor, drawn before the cube so it is always underneath */
.scene::before {
  content: '';
  position: absolute;
  bottom: 0;
  left: calc(50% - 64 * var(--u));
  width: calc(128 * var(--u));
  height: calc(20 * var(--u));
  border-radius: 50%;
  background: radial-gradient(closest-side, rgb(0 0 0 / 0.45), transparent);
}

.cube {
  --s: calc(116 * var(--u));
  position: relative;
  width: var(--s);
  height: var(--s);
  transform-style: preserve-3d;
  /* Read right to left: spin on the vertical axis by --angle (JS only ever adds or subtracts
     90deg steps, it never wraps back to 0), tip the top toward you a little, then pull the cube
     back by half a side so the front face sits at z = 0 and stays its true size. */
  transform: translateZ(calc(var(--s) / -2)) rotateX(-9deg) rotateY(var(--angle, 0deg));
  transition: transform 0.8s cubic-bezier(0.3, 1.2, 0.5, 1);
}

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

/* each side is a little landscape photo: sky, sun, two hills, a gloss and a shade for the text */
.cube i {
  position: absolute;
  inset: 0;
  display: grid;
  align-content: end;
  padding: calc(10 * var(--u)) calc(11 * var(--u));
  /* an inner line plus a soft shade instead of a hard border: squeezed side-on, a one-unit line
     breaks up, the shade survives */
  --edge: hsl(var(--hue) 90% 85% / 0.55);
  --edge-soft: hsl(var(--hue) 90% 85% / 0.16);
  box-shadow: inset 0 0 0 calc(1 * var(--u)) var(--edge), inset 0 0 calc(8 * var(--u)) var(--edge-soft);
  border-radius: calc(10 * var(--u));
  background:
    linear-gradient(155deg, rgb(255 255 255 / 0.22), transparent 38%),
    linear-gradient(transparent 52%, rgb(0 0 0 / 0.42)),
    radial-gradient(95% 55% at 82% 108%, hsl(var(--hue) 45% 19%) 70%, transparent 71%),
    radial-gradient(85% 50% at 16% 102%, hsl(var(--hue) 40% 33%) 70%, transparent 71%),
    radial-gradient(circle at 70% 34%, hsl(calc(var(--hue) + 20) 100% 93%) 0 10%, hsl(calc(var(--hue) + 20) 100% 85% / 0.35) 11% 17%, transparent 18%),
    linear-gradient(hsl(var(--hue) 80% 72%), hsl(calc(var(--hue) + 35) 70% 46%));
  color: #fff;
  font-size: calc(15 * var(--u));
  font-style: normal;
  font-weight: 800;
  line-height: 1.1;
  text-shadow: 0 calc(1 * var(--u)) calc(3 * var(--u)) rgb(0 0 0 / 0.4);
  backface-visibility: hidden;
  transform-style: preserve-3d;
}

/* rounded faces leave a hole where their corners meet: a plate just behind each face plugs it */
.cube i::before {
  content: '';
  position: absolute;
  /* one unit short of the edge: full size, it would touch the next face and show there as a
     dotted seam; much smaller leaves a channel along each edge you can see into */
  inset: calc(1 * var(--u));
  background: hsl(var(--hue) 45% 18%);
  transform: translateZ(calc(-6 * var(--u)));
}

.cube i small {
  font-size: calc(10 * var(--u));
  font-weight: 600;
  opacity: 0.85;
}

/* top and bottom: dark glass, so the photos are what you look at */
.cube i:nth-child(n + 5) {
  --hue: 250;
  --edge: rgb(140 150 220 / 0.34);
  --edge-soft: rgb(140 150 220 / 0.16);
  background:
    linear-gradient(135deg, rgb(255 255 255 / 0.12), transparent 60%),
    #221f45;
}

/* the control zone: the same object, at the same size, in every model that has one — so it is
   written in plain vmin and not in the cube's own unit. The caption is on its own line above the
   row, and its line box never changes height, so a new slide cannot move the cube. */
.controls {
  display: grid;
  justify-items: center;
  gap: 2vmin;
  text-align: center;
}

.controls .caption {
  font: 500 4.5vmin/1.2 Inter, system-ui, sans-serif;
  letter-spacing: 0.06em;
  opacity: 0.7;
}

.controls .row {
  display: flex;
  align-items: center;
  gap: 2vmin;
}

.controls button,
.controls label {
  height: 8vmin;
  min-width: 8vmin;
  padding: 0 3vmin;
  box-sizing: border-box; /* a label is content-box, so min-width would add to its padding */
  border: 0;
  border-radius: 999px;
  /* see-through, so the pill sits on the stage, and its word is the stage's own ink at full
     strength: no colour of the model's, which would vanish on one of the two stages */
  background: rgb(140 150 220 / 0.2);
  color: inherit;
  font: 600 4vmin Inter, system-ui, sans-serif;
  cursor: pointer;
  transition: background-color 0.35s;
}

.controls button:hover,
.controls label:hover {
  background-color: rgb(140 150 220 / 0.34);
}

.controls :focus-visible {
  outline: 0.6vmin solid #6a45f5;
  outline-offset: 0.6vmin;
}

/* the selected pill: the brand gradient, deep enough that white text on it passes */
.controls [aria-pressed='true'] {
  background: linear-gradient(135deg, #6a45f5, #d1206f);
  color: #fff;
}

.controls button {
  display: grid;
  place-items: center;
  padding: 0;
}

.controls svg {
  width: 4.5vmin;
  height: 4.5vmin;
}

.dots {
  display: flex;
  align-items: center;
  gap: 2vmin;
}

/* a dot is a small mark on a finger-sized button, not a small button */
.dots button {
  background: none;
}

/* the marks are the stage's ink, faded by opacity (a colour mixed from currentColor can keep the
   old theme's value on a live switch), so they read on both stages */
.dots button::before {
  content: '';
  width: 2vmin;
  height: 2vmin;
  border-radius: 999px;
  background: currentColor;
  opacity: 0.55;
  transition: width 0.3s, background 0.3s, opacity 0.3s;
}

/* the current one is a longer pill, in the zone's violet: 3:1 and over on both stages */
.dots button[aria-current='true']::before {
  width: 4.5vmin;
  background: #6a45f5;
  opacity: 1;
}

JS 31 lines

const cube = document.querySelector('.cube');
const row = document.querySelector('.controls .row');
const out = document.querySelector('output');
const dots = [...document.querySelectorAll('[data-go]')];
const slides = ['Dawn', 'Reef', 'Dusk', 'Night'];
const n = slides.length;
let index = 0; // unbounded: …, -1, 0, 1, 2, … 7, 8, …

function render() {
  const current = ((index % n) + n) % n;
  // face k sits at +90° × k around the cube, so showing it means turning by -90° × k
  cube.style.setProperty('--angle', index * -90 + 'deg');
  out.textContent = '0' + (current + 1) + ' · ' + slides[current];
  dots.forEach((d, i) => d.setAttribute('aria-current', String(i === current)));
}

row.addEventListener('click', (e) => {
  const btn = e.target.closest('button');
  if (!btn) return;
  if (btn.dataset.dir) {
    index += Number(btn.dataset.dir);
  } else if (btn.dataset.go) {
    const current = ((index % n) + n) % n;
    let delta = (Number(btn.dataset.go) - current + n) % n; // 0…3 steps forward
    if (delta > n / 2) delta -= n; // "3 forward" is really "1 back"
    index += delta;
  }
  render();
});

render();
Standalone snippet · plain CSS, no build step, no dependencies.