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.

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

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 / 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>
  <output class="caption" aria-live="polite">01 · Dawn</output>
  <div class="bar">
    <button type="button" data-dir="-1" aria-label="Previous"><svg viewBox="0 0 24 24" width="16" height="16" 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" width="16" height="16" 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>

CSS 154 lines

.cubenav {
  display: grid;
  grid-template-rows: 1fr auto auto;
  justify-items: center;
  gap: 8px;
  width: 320px;
  height: 280px;
  padding: 10px 14px 12px;
  font-family: system-ui, sans-serif;
}

.view {
  position: relative;
  display: grid;
  place-items: center;
  width: 100%;
  perspective: 700px;
}

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

.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
     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: 9px 10px;
  border: 1px solid hsl(var(--hue) 90% 85% / 0.55);
  border-radius: 10px;
  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: 15px;
  font-style: normal;
  font-weight: 800;
  line-height: 1.1;
  text-shadow: 0 1px 3px 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;
  inset: 0; /* full size: a smaller plate leaves a channel along each edge you can see into */
  background: hsl(var(--hue) 45% 18%);
  transform: translateZ(-6px);
}

.cube i small {
  font-size: 10px;
  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;
  border-color: rgb(140 150 220 / 0.34);
  background:
    linear-gradient(135deg, rgb(255 255 255 / 0.12), transparent 60%),
    #221f45;
}

.caption {
  color: #949bc0;
  font-size: 12px;
  font-weight: 700;
  letter-spacing: 0.06em;
}

/* one pill: previous, dots, next */
.bar {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 4px;
  border: 1px solid rgb(140 150 220 / 0.34);
  border-radius: 999px;
  background: rgb(7 8 15 / 0.7);
}

.bar > button {
  display: grid;
  place-items: center;
  width: 28px;
  height: 28px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: linear-gradient(135deg, #8b6cff, #ff4d9d);
  color: #fff;
  cursor: pointer;
}

.dots {
  display: flex;
  align-items: center;
  gap: 6px;
}

.dots button {
  width: 8px;
  height: 8px;
  padding: 0;
  border: 0;
  border-radius: 999px;
  background: rgb(140 150 220 / 0.34);
  cursor: pointer;
}

/* the current one is a longer pill */
.dots button[aria-current='true'] {
  width: 18px;
  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 = '0' + (current + 1) + ' · ' + 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 141 lines

@use '../mixins' as *;

// A photo gallery on the four sides of a cube: the cube, a caption, and one pill of controls.
.d-cubenav {
  display: grid;
  grid-template-rows: 1fr auto auto;
  justify-items: center;
  gap: 8px;
  width: 100%;
  height: 100%;
  padding: 10px 14px 14px; // the control dock: the same place in every demo with controls

  &__view {
    position: relative;
    display: grid;
    place-items: center;
    width: 100%;
    perspective: 700px;

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

  &__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(-9deg) rotateY(var(--angle, 0deg));
    transition: transform 0.8s cubic-bezier(0.3, 1.2, 0.5, 1);
    @include cube-faces(var(--s));

    // each side is a little landscape photo: sky, sun, two hills, a gloss and a shade for the text
    i {
      position: absolute;
      inset: 0;
      display: grid;
      align-content: end;
      padding: 9px 10px;
      border: 1px solid hsl(var(--hue) 90% 85% / 0.55);
      border-radius: 10px;
      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: 15px;
      font-style: normal;
      font-weight: 800;
      line-height: 1.1;
      text-shadow: 0 1px 3px rgb(0 0 0 / 0.4);
      backface-visibility: hidden;
      @include solid-core(6px, hsl(var(--hue) 45% 18%));

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

      // top and bottom: dark glass, so the photos are what you look at
      &:nth-child(n + 5) {
        --hue: 250;
        border-color: var(--border-strong);
        background:
          linear-gradient(135deg, rgb(255 255 255 / 0.12), transparent 60%),
          color-mix(in srgb, var(--accent) 18%, var(--surface-solid));
      }
    }
  }

  &__caption {
    color: var(--muted);
    font-size: 12px;
    font-weight: 700;
    letter-spacing: 0.06em;
  }

  // one pill: ‹ dots ›
  &__bar {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 4px;
    border: 1px solid var(--border-strong);
    border-radius: 999px;
    background: color-mix(in srgb, var(--bg) 70%, transparent);

    > button {
      display: grid;
      place-items: center;
      width: 28px;
      height: 28px;
      padding: 0;
      border: 0;
      border-radius: 50%;
      background: linear-gradient(135deg, var(--accent), var(--hot));
      color: #fff;
      cursor: pointer;
      --icon: 16px;
    }
  }

  &__dots {
    display: flex;
    align-items: center;
    gap: 6px;

    button {
      width: 8px;
      height: 8px;
      padding: 0;
      border: 0;
      border-radius: 999px;
      background: var(--border-strong);
      cursor: pointer;

      // the current one is a longer pill (changed at once, not animated: width is not a transform)
      &[aria-current='true'] {
        width: 18px;
        background: var(--accent-2);
      }
    }
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.