CSS + JS Products & branding #controls #pointer #product #drag #viewer

Product turntable in CSS + JavaScript

Drag sideways to spin the speaker; let go and it coasts to a stop. JS only tracks one angle and one colour, both handed to CSS as custom properties.

  • 241 lines of CSS
  • 21 lines of HTML
  • 60 lines of JS
  • No dependencies
  • Snippet under MIT
GitHub

How it works

  1. The speaker is a box, not a cube: every face gets its own width and height, turns outward and steps out by half the dimension it faces (front by depth / 2, sides by width / 2, top by height / 2).
  2. JS tracks one number. On pointerdown it captures the pointer, each pointermove adds the sideways delta to the angle and writes it as --ry; CSS does the rotating.
  3. On release it keeps the last delta as a velocity and runs a short requestAnimationFrame loop that loses 6% per frame and stops itself once the speed is tiny: inertia without a loop running forever.
  4. touch-action: pan-y gives sideways drags to the viewer and leaves vertical swipes to the page. The swatches just set --c, and every face mixes its colour from it.
  5. Every length in the speaker is a multiple of one base unit, --u, tied to the canvas: it is 86 × 124 × 72 of them, so it is the same share of a gallery card, the editor and a recording canvas. The swatches are in plain vmin, the same size in every model.

Key techniques

  • pointer capture + drag delta → --ry
  • inertia rAF that stops when slow
  • colour swatches set --c
  • touch-action: pan-y

Copy-paste code

HTML 21 lines

<div class="viewer">
  <div class="view" tabindex="0" aria-label="Speaker. Drag sideways or use the arrow keys to rotate it">
    <div class="product">
      <i class="f front"><b></b><b></b></i>
      <i class="f back"></i>
      <i class="f left"></i>
      <i class="f right"></i>
      <i class="f top"></i>
      <i class="f bottom"></i>
      <i class="shadow"></i>
    </div>
  </div>
  <div class="controls">
    <output class="caption">drag to rotate</output>
    <div class="row">
      <button type="button" data-c="#8b6cff" style="--sw:#8b6cff" aria-label="Violet" aria-pressed="true"></button>
      <button type="button" data-c="#2ee6d6" style="--sw:#2ee6d6" aria-label="Teal" aria-pressed="false"></button>
      <button type="button" data-c="#ff4d9d" style="--sw:#ff4d9d" aria-label="Pink" aria-pressed="false"></button>
    </div>
  </div>
</div>

CSS 241 lines

.viewer {
  /* one base unit: every length below is a multiple of it, so the speaker is the same share of
     a 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.22vmin;
  --c: #8b6cff;
  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 {
  box-sizing: border-box;
  display: grid;
  place-items: center;
  height: 44vmin;
  /* the round shadow hangs below the box: the room for it keeps the speaker centred */
  padding-bottom: calc(26 * var(--u));
  border-radius: calc(14 * var(--u));
  outline-offset: calc(-4 * var(--u));
  perspective: calc(1200 * var(--u)); /* a camera well back, so the verticals stay upright */
  cursor: grab;
  user-select: none;
  touch-action: pan-y; /* sideways drags are ours, vertical ones scroll the page */
}

.view.is-dragging {
  cursor: grabbing;
}

/* 86 units wide, 124 tall, 72 deep. No transition: JS sets a new angle every frame */
.product {
  position: relative;
  width: calc(86 * var(--u));
  height: calc(124 * var(--u));
  pointer-events: none;
  transform-style: preserve-3d;
  transform: rotateX(-14deg) rotateY(var(--ry, -32deg));
}

.f {
  position: absolute;
  box-sizing: border-box;
  border: calc(1 * var(--u)) solid color-mix(in srgb, var(--c) 55%, #fff);
  /* a transparent line outside the border: the turned face's hard outer edge stays invisible,
     so the light edge line is smoothed instead of stair-stepping */
  outline: calc(1 * var(--u)) solid transparent;
  /* woven fabric: a fine dot grid over a light falloff */
  background:
    radial-gradient(rgb(0 0 0 / 0.22) calc(0.7 * var(--u)), transparent calc(1.2 * var(--u))) 0 0 / calc(4 * var(--u)) calc(4 * var(--u)),
    linear-gradient(color-mix(in srgb, var(--c) 85%, #fff), color-mix(in srgb, var(--c) 70%, #0b0d18));
}

.front,
.back {
  inset: 0;
}

.front {
  display: grid;
  place-items: center;
  align-content: space-evenly;
  transform: translateZ(calc(36 * var(--u))); /* depth / 2 */
}

.back {
  background:
    radial-gradient(circle at 50% 72%, #05060c 0 calc(9 * var(--u)), color-mix(in srgb, var(--c) 40%, #05060c) calc(10 * var(--u)) calc(12 * var(--u)), transparent calc(12.5 * var(--u))),
    linear-gradient(color-mix(in srgb, var(--c) 55%, #0b0d18), color-mix(in srgb, var(--c) 35%, #0b0d18));
  transform: rotateY(180deg) translateZ(calc(36 * var(--u)));
}

/* depth-wide, centred on the box */
.left,
.right {
  top: 0;
  left: calc(7 * var(--u));
  width: calc(72 * var(--u));
  height: calc(124 * var(--u));
  background:
    radial-gradient(rgb(0 0 0 / 0.22) calc(0.7 * var(--u)), transparent calc(1.2 * var(--u))) 0 0 / calc(4 * var(--u)) calc(4 * var(--u)),
    linear-gradient(color-mix(in srgb, var(--c) 65%, #0b0d18), color-mix(in srgb, var(--c) 45%, #0b0d18));
}

/* the sides are squeezed hard on a small canvas: a two-unit highlight inside both upright edges
   survives it where a one-unit line breaks up */
.left,
.right {
  box-shadow:
    inset calc(2 * var(--u)) 0 0 color-mix(in srgb, color-mix(in srgb, var(--c) 55%, #fff) 70%, transparent),
    inset calc(-2 * var(--u)) 0 0 color-mix(in srgb, color-mix(in srgb, var(--c) 55%, #fff) 70%, transparent);
}

.left { transform: rotateY(-90deg) translateZ(calc(43 * var(--u))); }  /* width / 2 */
.right { transform: rotateY(90deg) translateZ(calc(43 * var(--u))); }

.top,
.bottom {
  top: calc(26 * var(--u));
  left: 0;
  width: calc(86 * var(--u));
  height: calc(72 * var(--u));
}

/* lighter, with three buttons */
.top {
  background:
    radial-gradient(circle at 30% 50%, rgb(255 255 255 / 0.8) 0 calc(3 * var(--u)), transparent calc(3.5 * var(--u))),
    radial-gradient(circle at 50% 50%, rgb(255 255 255 / 0.8) 0 calc(3 * var(--u)), transparent calc(3.5 * var(--u))),
    radial-gradient(circle at 70% 50%, rgb(255 255 255 / 0.8) 0 calc(3 * var(--u)), transparent calc(3.5 * var(--u))),
    color-mix(in srgb, var(--c) 70%, #fff);
  transform: rotateX(90deg) translateZ(calc(62 * var(--u))); /* height / 2 */
}

.bottom {
  background: color-mix(in srgb, var(--c) 30%, #05060c);
  transform: rotateX(-90deg) translateZ(calc(62 * var(--u)));
}

/* tweeter and woofer */
.front b {
  width: calc(24 * var(--u));
  height: calc(24 * var(--u));
  border-radius: 50%;
  background:
    radial-gradient(circle at 40% 35%, rgb(255 255 255 / 0.5), transparent 30%),
    radial-gradient(circle, #d4d7ea 0 18%, #1a1c2b 22% 58%, #34374f 62% 72%, #05060c 76% 84%, color-mix(in srgb, var(--c) 40%, #fff) 88%);
}

.front b + b {
  width: calc(58 * var(--u));
  height: calc(58 * var(--u));
  background:
    radial-gradient(circle at 40% 35%, rgb(255 255 255 / 0.3), transparent 30%),
    radial-gradient(circle, #d4d7ea 0 12%, #1a1c2b 15% 50%, #2b2e44 52% 60%, #1a1c2b 62% 72%, #05060c 76% 86%, color-mix(in srgb, var(--c) 40%, #fff) 90%);
}

/* round, so the rotation does not matter; laid flat just under the bottom */
.shadow {
  position: absolute;
  top: calc(50% - 80 * var(--u));
  left: calc(50% - 80 * var(--u));
  width: calc(160 * var(--u));
  height: calc(160 * var(--u));
  border-radius: 50%;
  background: radial-gradient(closest-side, rgb(0 0 0 / 0.5), rgb(0 0 0 / 0.18) 60%, transparent);
  transform: rotateX(90deg) translateZ(calc(-64 * var(--u)));
}

/* 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 speaker's own unit. The caption is on its own line above
   the row, and its line box never changes height, so a new angle cannot move the speaker. */
.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;
}

/* The pill already chosen answers the pointer too.

   Hover sets background-color; this rule sets the background SHORTHAND, so its gradient image was
   painted straight over that colour. The one control already selected was the one control that
   did nothing when you pointed at it, which is what check-motion kept saying:
   'hover button#1 "Low": changes nothing on screen'.

   Darker, not lighter: the gradient above is as pale as it can be with white text still passing. */
.controls [aria-pressed='true']:hover {
  background-image: linear-gradient(135deg, #5730dd, #b31859);
}

/* a swatch: its colour is what it says, so it keeps it when selected, and the selected one is
   ringed in the stage's own ink */
.controls .row button {
  box-sizing: border-box;
  width: 8vmin;
  padding: 0;
  border: 0.6vmin solid #141830;
  background: var(--sw);
  box-shadow: 0 0 0 0.3vmin rgb(140 150 220 / 0.34);
  transition: box-shadow 0.35s;
}

/* the swatch's own colour wins over the block's hover tint, so a pointed-at or focused swatch
   answers with a ring in the stage's ink instead: thinner than the selected one's, so the two
   states stay apart. It lights up in 0.12s and fades in 0.35s */
.controls .row button[aria-pressed='false']:is(:hover, :focus-visible) {
  box-shadow: 0 0 0 0.35vmin currentColor;
  transition-duration: 0.12s;
}

.controls .row button[aria-pressed='true'] {
  box-shadow: 0 0 0 0.6vmin currentColor;
  color: inherit;
}

JS 60 lines

const viewer = document.querySelector('.viewer');
const view = viewer.querySelector('.view');
const product = viewer.querySelector('.product');
const controls = viewer.querySelector('.controls');
const out = viewer.querySelector('output');
let ry = -32, v = 0, last = 0, raf = 0, dragging = false;

function apply() {
  product.style.setProperty('--ry', ry.toFixed(1) + 'deg');
  out.textContent = Math.round(((ry % 360) + 360) % 360) + '°';
}

// inertia: keep turning, lose 6% per frame, stop the loop when slow
function glide() {
  v *= 0.94;
  ry += v;
  apply();
  raf = Math.abs(v) > 0.05 ? requestAnimationFrame(glide) : 0;
}

view.addEventListener('pointerdown', (e) => {
  dragging = true;
  last = e.clientX;
  v = 0;
  cancelAnimationFrame(raf);
  view.setPointerCapture(e.pointerId); // keep getting moves outside the view
  view.classList.add('is-dragging');
});

view.addEventListener('pointermove', (e) => {
  if (!dragging) return;
  v = (e.clientX - last) * 0.6;
  last = e.clientX;
  ry += v;
  apply();
});

function up() {
  if (!dragging) return;
  dragging = false;
  view.classList.remove('is-dragging');
  if (Math.abs(v) > 0.05) raf = requestAnimationFrame(glide);
}
view.addEventListener('pointerup', up);
view.addEventListener('pointercancel', up);

view.addEventListener('keydown', (e) => {
  if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
  e.preventDefault();
  v = e.key === 'ArrowLeft' ? -5 : 5;
  cancelAnimationFrame(raf);
  raf = requestAnimationFrame(glide);
});

controls.addEventListener('click', (e) => {
  const btn = e.target.closest('[data-c]');
  if (!btn) return;
  viewer.style.setProperty('--c', btn.dataset.c);
  controls.querySelectorAll('[data-c]').forEach((b) => b.setAttribute('aria-pressed', String(b === btn)));
});
Standalone snippet · plain CSS, no build step, no dependencies.