CSS + JS Cards & galleries #controls #shape

Box slideshow in CSS + JavaScript

Four slides on the sides of a box. JS counts steps; CSS turns the box by 90° per step.

  • 108 lines of CSS
  • 20 lines of HTML
  • 9 lines of JS
  • No dependencies
  • Snippet under MIT
GitHub

How it works

  1. Four slides are the four side faces of a box: rotateY(n × 90deg) translateZ(width / 2).
  2. The box is pulled back by translateZ(-width / 2), so whichever face is in front sits at z = 0 and renders at its true size.
  3. JS keeps a plain counter that never wraps. CSS multiplies it: rotateY(calc(var(--step) * -90deg)).
  4. Because the angle keeps growing instead of resetting, "next" always turns the same way — no rewind from slide 4 to slide 1.
  5. Every length in the box is a multiple of one base unit, --u, tied to the canvas: a slide is 280 × 170 of them, so the box is the same share of a gallery card, the editor and a recording canvas. The arrows are in plain vmin, the same size in every model that has controls.

Key techniques

  • rotateY(calc(var(--step) * -90deg))
  • unbounded counter = endless rotation
  • translateZ(-w/2) keeps the front at z = 0

Copy-paste code

HTML 20 lines

<div class="slider">
  <div class="scene">
    <div class="box">
      <i style="--hue:250">Design</i>
      <i style="--hue:290">Build</i>
      <i style="--hue:330">Ship</i>
      <i style="--hue:10">Repeat</i>
    </div>
  </div>
  <div class="controls">
    <nav 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" aria-hidden="true"><path d="m15 18-6-6 6-6"/></svg>
      </button>
      <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" aria-hidden="true"><path d="m9 18 6-6-6-6"/></svg>
      </button>
    </nav>
  </div>
</div>

CSS 108 lines

.slider {
  /* one base unit: every length in the box below is a multiple of it, so the slideshow is the
     same share of a gallery card, the editor, a full screen and a recording canvas. The control
     row 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;
}

.scene {
  perspective: calc(800 * var(--u));
}

.box {
  --w: calc(280 * var(--u));
  position: relative;
  width: var(--w);
  height: calc(170 * var(--u));
  transform-style: preserve-3d;
  transform: translateZ(calc(var(--w) / -2)) rotateY(calc(var(--step, 0) * -90deg));
  transition: transform 0.7s cubic-bezier(0.3, 1.25, 0.5, 1);
}

.box i {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  border-radius: calc(8 * var(--u));
  color: #fff;
  font: 900 calc(32 * var(--u)) Inter, system-ui;
  background: linear-gradient(135deg, hsl(var(--hue) 85% 62%), hsl(var(--hue) 75% 38%));
  backface-visibility: hidden;
  transform-style: preserve-3d;
}

/* plugs the see-through notches where the rounded corners of two slides meet */
.box i::before {
  content: '';
  position: absolute;
  inset: calc(6 * var(--u));
  background: hsl(var(--hue) 75% 34%);
  transform: translateZ(calc(-6 * var(--u)));
}

.box i:nth-child(1) { transform: rotateY(0deg)   translateZ(calc(var(--w) / 2)); }
.box i:nth-child(2) { transform: rotateY(90deg)  translateZ(calc(var(--w) / 2)); }
.box i:nth-child(3) { transform: rotateY(180deg) translateZ(calc(var(--w) / 2)); }
.box i:nth-child(4) { transform: rotateY(270deg) translateZ(calc(var(--w) / 2)); }

/* 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 box's own unit */
.controls {
  display: grid;
  justify-items: center;
  gap: 2vmin;
  text-align: center;
}

.controls .row {
  display: flex;
  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;
}

/* SVG arrows, not text like ‹ ›: a glyph sits on the font's baseline, so it never centres */
.controls svg {
  width: 4.5vmin;
  height: 4.5vmin;
}

JS 9 lines

const box = document.querySelector('.box');
let step = 0;   // never wraps, so the box keeps turning the same way

document.querySelector('nav').addEventListener('click', (e) => {
  const btn = e.target.closest('[data-dir]');
  if (!btn) return;
  step += Number(btn.dataset.dir);
  box.style.setProperty('--step', step);
});
Standalone snippet · plain CSS, no build step, no dependencies.