CSS + JS Shapes & solids #pointer

Drag-to-rotate cube in CSS + JavaScript

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

  • 36 lines of CSS
  • 6 lines of HTML
  • 46 lines of JS
  • No dependencies
  • MIT licensed
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.

Key techniques

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

Copy-paste code

HTML 6 lines

<div class="scene">
  <div class="cube">
    <div></div><div></div><div></div>
    <div></div><div></div><div></div>
  </div>
</div>

CSS 36 lines

.scene {
  display: grid;
  place-items: center;
  width: 100vw;
  height: 100vh;
  perspective: 800px;
  cursor: grab;
  touch-action: none;   /* let JS have the drag on touch screens */
  user-select: none;
}

.scene:active { cursor: grabbing; }

.cube {
  --s: 140px;
  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: 1px 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)); }

JS 46 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);
}
scene.addEventListener('pointerup', release);
scene.addEventListener('pointercancel', release);

Sass source 43 lines

@use '../mixins' as *;

.d-drag {
  display: grid;
  place-items: center;
  width: 100%;
  height: 100%;
  transform-style: preserve-3d;
  cursor: grab;
  user-select: none;

  &:active {
    cursor: grabbing;
  }

  &__cube {
    --s: 100px;
    position: relative;
    width: var(--s);
    height: var(--s);
    transform-style: preserve-3d;
    // The only thing JS touches: two angles.
    transform: rotateX(var(--rx, -24deg)) rotateY(var(--ry, 32deg));
    @include cube-faces(var(--s));

    i {
      position: absolute;
      inset: 0;
      @include face(var(--warm), 30%);
      background-image:
        linear-gradient(rgb(255 255 255 / 0.25) 1px, transparent 1px),
        linear-gradient(90deg, rgb(255 255 255 / 0.25) 1px, transparent 1px);
      background-size: 25% 25%;
    }
  }

  small {
    position: absolute;
    bottom: 10px;
    color: var(--muted);
    font-size: 12px;
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.