CSS + JS Shapes & solids #pointer #drag

Drag-to-rotate cube in CSS + JavaScript

Drag to spin it, release to let it coast. JS only tracks two angles and some inertia.

  • 44 lines of CSS
  • 6 lines of HTML
  • 56 lines of JS
  • No dependencies
  • Snippet under MIT
GitHub

How it works

  1. The cube is the same pure-CSS cube; its rotation is simply rotateX(var(--rx)) rotateY(var(--ry)).
  2. JS tracks a drag with Pointer Events (mouse, touch and pen in one API) and updates those two angles.
  3. setPointerCapture keeps the drag alive when the pointer leaves the element; touch-action: none stops the page scrolling instead.
  4. On release, the last velocity keeps being applied and multiplied by 0.95 each frame — cheap inertia.
  5. Every length is a multiple of one base unit, --u, so the cube is the same share of a gallery card, the editor and a recording canvas. It is sized for the worst angle a drag can reach — corner-on, where it spans its body diagonal, √3 × its side — not for the rest pose.

Key techniques

  • Pointer Events + setPointerCapture
  • requestAnimationFrame inertia
  • touch-action: none

Copy-paste code

HTML 6 lines

<div class="scene" tabindex="0" role="img" aria-label="Wireframe cube. Drag it, or use the arrow keys, to turn it">
  <div class="cube">
    <div></div><div></div><div></div>
    <div></div><div></div><div></div>
  </div>
</div>

CSS 44 lines

.scene {
  display: grid;
  place-items: center;
  width: 100%;
  height: 100%;
  /* one base unit: every length below is a multiple of it, so the cube is the same share of a
     card, the editor, a full screen and a recording canvas. Sized for the worst drag: turned
     corner-on, the cube spans its body diagonal, √3 × 140 units */
  --u: 0.26vmin;
  perspective: calc(800 * var(--u));
  cursor: grab;
  touch-action: none;   /* let JS have the drag on touch screens */
  user-select: none;
}

.scene:active { cursor: grabbing; }

.cube {
  --s: calc(140 * var(--u));
  position: relative;
  width: var(--s);
  height: var(--s);
  transform-style: preserve-3d;
  transform: rotateX(var(--rx, -24deg)) rotateY(var(--ry, 32deg));
}

.cube > * {
  position: absolute;
  inset: 0;
  background: rgb(255 181 71 / 0.3);
  border: calc(1 * var(--u)) solid rgb(255 181 71 / 0.85);
}

/* 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)); }

/* the scene covers the canvas, so a ring round it would be off the canvas: focus lights the cube */
.scene:focus-visible { outline: none; }
.scene:focus-visible .cube > * { border-color: #fff; }

JS 56 lines

const scene = document.querySelector('.scene');
const cube = document.querySelector('.cube');

let rx = -24, ry = 32;   // current angles
let vx = 0, vy = 0;      // velocity, for inertia
let dragging = false;
let raf = 0;

function apply() {
  rx = Math.max(-89, Math.min(89, rx));
  cube.style.setProperty('--rx', rx + 'deg');
  cube.style.setProperty('--ry', ry + 'deg');
}

function coast() {
  if (dragging) return;
  vx *= 0.95;
  vy *= 0.95;
  rx += vx;
  ry += vy;
  apply();
  if (Math.abs(vx) + Math.abs(vy) > 0.05) raf = requestAnimationFrame(coast);
}

scene.addEventListener('pointerdown', (e) => {
  dragging = true;
  cancelAnimationFrame(raf);
  scene.setPointerCapture(e.pointerId);
});

scene.addEventListener('pointermove', (e) => {
  if (!dragging) return;
  vy = e.movementX * 0.6;
  vx = -e.movementY * 0.6;
  rx += vx;
  ry += vy;
  apply();
});

function release() {
  if (!dragging) return;
  dragging = false;
  raf = requestAnimationFrame(coast);
}
// the keyboard: each arrow key turns it 10 degrees
scene.addEventListener('keydown', (e) => {
  const step = { ArrowLeft: [0, -10], ArrowRight: [0, 10], ArrowUp: [10, 0], ArrowDown: [-10, 0] }[e.key];
  if (!step) return;
  e.preventDefault();
  cancelAnimationFrame(raf);
  rx += step[0];
  ry += step[1];
  apply();
});
scene.addEventListener('pointerup', release);
scene.addEventListener('pointercancel', release);
Standalone snippet · plain CSS, no build step, no dependencies.