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.

  • 118 lines of CSS
  • 23 lines of HTML
  • 31 lines of JS
  • No dependencies
  • MIT licensed
GitHub

How it works

  1. JS keeps one unbounded counter (…, -1, 0, 1, 2, … 7, 8, …), never a value clamped to 0-3. CSS just multiplies it: rotateY(calc(index * -90deg)).
  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.

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

<div class="cubenav">
  <div class="view">
    <div class="cube">
      <i style="--hue:28">Dawn<small>01</small></i>
      <i style="--hue:178">Reef<small>02</small></i>
      <i style="--hue:300">Dusk<small>03</small></i>
      <i style="--hue:240">Night<small>04</small></i>
      <i></i>
      <i></i>
    </div>
  </div>
  <div class="bar">
    <button type="button" data-dir="-1">Prev</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">Next</button>
    <output>1 / 4 · Dawn</output>
  </div>
</div>

CSS 118 lines

.cubenav {
  display: grid;
  grid-template-rows: 1fr auto;
  width: 320px;
  height: 260px;
  padding: 10px 14px;
}

.view {
  display: grid;
  place-items: center;
  perspective: 700px;
}

.cube {
  --s: 116px;
  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
     90° steps, it never wraps back to 0), tip the top toward the viewer a little, then pull the
     whole 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(-14deg) 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)); }

.cube i {
  position: absolute;
  inset: 0;
  display: grid;
  align-content: end;
  padding: 10px;
  border: 1px solid hsl(var(--hue) 90% 80% / 0.6);
  border-radius: 10px;
  background:
    radial-gradient(circle at 72% 26%, rgb(255 255 255 / 0.55) 0 9%, transparent 10%),
    linear-gradient(165deg, hsl(var(--hue) 85% 66%), hsl(calc(var(--hue) + 45) 70% 30%));
  color: #fff;
  font-size: 15px;
  font-style: normal;
  font-weight: 800;
  line-height: 1.1;
  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;
  inset: 6px;
  background: hsl(var(--hue) 60% 24%);
  transform: translateZ(-6px);
}

.cube i small {
  font-size: 10px;
  font-weight: 600;
  opacity: 0.8;
}

/* top and bottom caps */
.cube i:nth-child(n + 5) {
  --hue: 250;
  border-color: rgb(140 150 220 / 0.34);
  background: color-mix(in srgb, #8b6cff 30%, #141830);
}

.bar {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 12px;
}

.bar button {
  padding: 5px 12px;
  border: 0;
  border-radius: 8px;
  background: #8b6cff;
  color: #fff;
  font-weight: 800;
  cursor: pointer;
}

.bar output {
  margin-left: auto;
  color: #949bc0;
  white-space: nowrap;
}

.dots {
  display: flex;
  gap: 6px;
}

.dots button {
  width: 10px;
  height: 10px;
  padding: 0;
  border: 1px solid rgb(140 150 220 / 0.34);
  border-radius: 50%;
  background: transparent;
}

.dots button[aria-current='true'] {
  border-color: #2ee6d6;
  background: #2ee6d6;
}

JS 31 lines

const cube = document.querySelector('.cube');
const bar = document.querySelector('.bar');
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 = (current + 1) + ' / ' + n + ' · ' + slides[current];
  dots.forEach((d, i) => d.setAttribute('aria-current', String(i === current)));
}

bar.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();

Sass source 104 lines

@use '../mixins' as *;

.d-cubenav {
  display: grid;
  grid-template-rows: 1fr auto;
  width: 100%;
  height: 100%;
  padding: 10px 14px;

  &__view {
    display: grid;
    place-items: center;
    perspective: 700px;
  }

  &__cube {
    --s: 116px;
    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
    // 90° steps, it never wraps back to 0), tip the top toward the viewer a little, then pull the
    // whole 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(-14deg) rotateY(var(--angle, 0deg));
    transition: transform 0.8s cubic-bezier(0.3, 1.2, 0.5, 1);
    @include cube-faces(var(--s));

    i {
      position: absolute;
      inset: 0;
      display: grid;
      align-content: end;
      padding: 10px;
      border: 1px solid hsl(var(--hue) 90% 80% / 0.6);
      border-radius: 10px;
      background:
        radial-gradient(circle at 72% 26%, rgb(255 255 255 / 0.55) 0 9%, transparent 10%),
        linear-gradient(165deg, hsl(var(--hue) 85% 66%), hsl(calc(var(--hue) + 45) 70% 30%));
      color: #fff;
      font-size: 15px;
      font-style: normal;
      font-weight: 800;
      line-height: 1.1;
      backface-visibility: hidden;
      @include solid-core(6px, hsl(var(--hue) 60% 24%));

      small {
        font-size: 10px;
        font-weight: 600;
        opacity: 0.8;
      }

      // top and bottom caps
      &:nth-child(n + 5) {
        --hue: 250;
        border-color: var(--border-strong);
        background: color-mix(in srgb, var(--accent) 30%, var(--surface-solid));
      }
    }
  }

  &__bar {
    display: flex;
    align-items: center;
    gap: 8px;
    font-size: 12px;

    button {
      padding: 5px 12px;
      border: 0;
      border-radius: 8px;
      background: var(--accent);
      color: #fff;
      font-weight: 800;
      cursor: pointer;
    }

    output {
      margin-left: auto;
      color: var(--muted);
      white-space: nowrap;
    }
  }

  &__dots {
    display: flex;
    gap: 6px;

    button {
      width: 10px;
      height: 10px;
      padding: 0;
      border: 1px solid var(--border-strong);
      border-radius: 50%;
      background: transparent;

      &[aria-current='true'] {
        border-color: var(--accent-2);
        background: var(--accent-2);
      }
    }
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.