CSS + JS Cards & galleries #controls

Coverflow in CSS + JavaScript

JS gives each cover its offset from the active one; CSS calc() does the placement and easing.

  • 124 lines of CSS
  • 22 lines of HTML
  • 36 lines of JS
  • No dependencies
  • Snippet under MIT
GitHub

How it works

  1. JS knows which cover is active and gives every cover three numbers: --o (offset from active), --abs and --sign.
  2. CSS turns those into position, depth and angle with calc() — JS never writes a transform.
  3. Because only custom properties change, a plain CSS transition animates every move.
  4. z-index by distance keeps nearer covers on top.
  5. Only the two covers either side of the active one are drawn. Seven fanned out in full are nearly twice the width of the canvas, so the far ones are faded right out — which is what a real cover flow does anyway, and it is what holds the width still however long the list gets.
  6. The covers are written in one base unit, --u, so they are the same share of a gallery card, the editor and a recording canvas. The arrows below sit in the standard control zone, in plain vmin, because that is the same object in every model that has one.

Key techniques

  • calc() with per-item custom properties
  • transition
  • keyboard arrows

Copy-paste code

HTML 22 lines

<div class="coverflow">
  <div class="view" tabindex="0" aria-label="Cover flow. Use the left and right arrow keys">
    <div class="track">
      <i style="--hue:250">1</i><i style="--hue:272">2</i>
      <i style="--hue:294">3</i><i style="--hue:316">4</i>
      <i style="--hue:338">5</i><i style="--hue:360">6</i>
      <i style="--hue:22">7</i>
    </div>
  </div>

  <div class="controls">
    <output class="caption" aria-live="polite">4 / 7</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" 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>
    </div>
  </div>
</div>

CSS 124 lines

.coverflow {
  /* one base unit: every length in the covers is a multiple of it, so the flow 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: 50vmin;
  perspective: calc(700 * var(--u));
  outline-offset: calc(6 * var(--u));
}

.track {
  position: relative;
  width: calc(130 * var(--u));
  height: calc(165 * var(--u));
  transform-style: preserve-3d;
  pointer-events: none;   /* same plane as the active cover: let the covers take the pointer */
}

.track i {
  /* --o, --abs and --sign are set per cover from JS */
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  border-radius: calc(12 * var(--u));
  font: 900 calc(35 * var(--u)) Inter, system-ui;
  color: #fff;
  cursor: pointer;
  pointer-events: auto;
  /* deep enough for the white numbers, 3:1 or more at their size, on either stage */
  background: linear-gradient(160deg, hsl(var(--hue) 80% 56%), hsl(var(--hue) 70% 34%));
  box-shadow: 0 calc(16 * var(--u)) calc(24 * var(--u)) calc(-14 * var(--u)) #000;
  /* the next card each side stays whole and dims with brightness, not opacity: see-through, on the
     light stage its number went pale (2.7:1); the ones further out fade away as before */
  filter: brightness(calc(1 - var(--abs) * 0.12));
  opacity: clamp(0, 2.2 - var(--abs) * 0.8, 1);
  transform:
    translateX(calc(var(--o) * 44 * var(--u) + var(--sign) * 34 * var(--u)))
    translateZ(calc(var(--abs) * -60 * var(--u)))
    rotateY(calc(var(--sign) * -58deg));
  transition: transform 0.55s cubic-bezier(0.2, 0.8, 0.2, 1), opacity 0.55s, filter 0.55s;
}

/* Anything more than two out is off the end of the flow: gone, and out of hit-testing with it,
   so a click never lands on a cover you cannot see. This is what holds the width still. */
.track i.is-far {
  opacity: 0;
  pointer-events: none;
}

/* 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 covers' own unit. The caption's line box never changes
   height, so counting up cannot move the flow above it. */
.controls {
  display: grid;
  justify-items: center;
  gap: 2vmin;
  text-align: center;
}

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

.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: 4vmin;
  height: 4vmin;
}

JS 36 lines

const root = document.querySelector('.coverflow');
const items = [...root.querySelectorAll('.track i')];
const caption = root.querySelector('.caption');
let active = Math.floor(items.length / 2);

function layout() {
  items.forEach((el, i) => {
    const o = i - active;
    el.style.setProperty('--o', o);
    el.style.setProperty('--abs', Math.abs(o));
    el.style.setProperty('--sign', Math.sign(o));
    el.style.zIndex = items.length - Math.abs(o);
    // past the third cover out there is nothing left to see: drop it, and its hit area with it
    el.classList.toggle('is-far', Math.abs(o) > 2);
  });
  caption.textContent = (active + 1) + ' / ' + items.length;
}

function go(dir) {
  active = Math.max(0, Math.min(items.length - 1, active + dir));
  layout();
}

root.addEventListener('click', (e) => {
  const btn = e.target.closest('[data-dir]');
  if (btn) return go(Number(btn.dataset.dir));
  const i = items.indexOf(e.target);
  if (i >= 0) go(i - active);
});

root.addEventListener('keydown', (e) => {
  if (e.key === 'ArrowLeft') go(-1);
  if (e.key === 'ArrowRight') go(1);
});

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