CSS + JS Data & tools #pointer #drag #chart #data #generated #loop #billboard

3D scatter plot in CSS + JavaScript

Points placed with translate3d inside a wireframe box that spins by CSS. Dragging pauses the spin and adds a manual rotation; every point counter-rotates so it stays a round sphere.

  • 122 lines of CSS
  • 14 lines of HTML
  • 46 lines of JS
  • No dependencies
  • MIT licensed

How it works

  1. Three nested rotations: tilt rotateX(--rx)spin rotateY(0 → 360deg) as a CSS animation › cube rotateY(--ry). Dragging sets the two variables and pauses the spin with animation-play-state.
  2. Points are placed with the translate property (translate: x y z) inside the wireframe cube, so each one keeps its position whatever else it does.
  3. To stay a round sphere a point undoes all three rotations. The two Y turns share an axis, so their order does not matter: the animated one goes on the separate rotate property (0 → -360deg, same timing as the spin), the variable ones stay in a static transform. No custom property ever has to be read inside keyframes.
  4. Pause the spin and the points together, and they stay in step. touch-action: none plus pointer capture makes the drag work with a finger.

Key techniques

  • translate3d(x, y, z) per point
  • nested rotations: tilt › spin › drag
  • animation-play-state while dragging
  • billboard: static inverse + animated rotate property

Copy-paste code

HTML 14 lines

<div class="plot">
  <div class="tilt">
    <div class="spin">
      <div class="cube">
        <u></u><u></u><u></u><u></u><u></u><u></u>
        <s></s><s></s><s></s>
        <b class="label" style="--x:84px;--y:70px;--z:-70px">x</b>
        <b class="label" style="--x:-70px;--y:-84px;--z:-70px">y</b>
        <b class="label" style="--x:-70px;--y:70px;--z:84px">z</b>
      </div>
    </div>
  </div>
  <small class="hint">drag to rotate</small>
</div>

CSS 122 lines

.plot {
  position: fixed;
  inset: 0;
  display: grid;
  place-items: center;
  perspective: 700px;
  cursor: grab;
  touch-action: none;
  user-select: none;
}

.plot.is-drag {
  cursor: grabbing;
}

.tilt {
  position: relative;
  width: 140px;
  height: 140px;
  margin-top: -14px; /* room for the hint */
  transform-style: preserve-3d;
  transform: scale3d(0.92, 0.92, 0.92) rotateX(var(--rx, -18deg));
}

.spin {
  position: absolute;
  inset: 0;
  transform-style: preserve-3d;
  animation: spin 28s linear infinite;
}

.cube {
  --s: 140px;
  position: absolute;
  inset: 0;
  transform-style: preserve-3d;
  transform: rotateY(var(--ry, 0deg));
}

/* wireframe walls with a faint grid */
.cube u {
  position: absolute;
  inset: 0;
  border: 1px solid rgb(236 238 251 / 0.22);
  background:
    linear-gradient(90deg, rgb(236 238 251 / 0.07) 1px, transparent 1px) 0 0 / 35px 35px,
    linear-gradient(rgb(236 238 251 / 0.07) 1px, transparent 1px) 0 0 / 35px 35px;
}

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

/* axes from the back-bottom-left corner */
.cube s {
  position: absolute;
  left: 0;
  top: 100%;
  width: 148px;
  height: 2px;
  background: rgb(236 238 251 / 0.55);
  transform-origin: 0 50%;
  transform: translateZ(-70px);                       /* x */
}
.cube s:nth-of-type(2) { transform: translateZ(-70px) rotateZ(-90deg); } /* y */
.cube s:nth-of-type(3) { transform: translateZ(-70px) rotateY(-90deg); } /* z */

.cube b {
  position: absolute;
  top: calc(50% - 5px);
  left: calc(50% - 5px);
  width: 10px;
  height: 10px;
  border-radius: 50%;
  background: radial-gradient(circle at 35% 30%, #fff 0 8%, var(--c) 50%, color-mix(in srgb, var(--c) 45%, #000));
  box-shadow: 0 0 8px var(--c);
  translate: var(--x) var(--y) var(--z);
  /* undo the cube's turn and the tilt (static)... */
  transform: rotateY(calc(var(--ry, 0deg) * -1)) rotateX(calc(var(--rx, -18deg) * -1));
  /* ...and the spin (animated, on its own property) */
  animation: face 28s linear infinite;
}

.cube .label {
  display: grid;
  place-items: center;
  background: none;
  box-shadow: none;
  color: #949bc0;
  font: 700 12px/1 monospace;
}

.is-drag .spin,
.is-drag .cube b {
  animation-play-state: paused;
}

.hint {
  position: absolute;
  inset: auto 0 10px;
  color: #949bc0;
  font: 12px system-ui;
  text-align: center;
  transition: opacity 0.4s;
}

.is-touched .hint {
  opacity: 0;
}

@keyframes spin {
  to { transform: rotateY(360deg); }
}

@keyframes face {
  from { rotate: y 0deg; }
  to   { rotate: y -360deg; }
}

JS 46 lines

const plot = document.querySelector('.plot');
const cube = plot.querySelector('.cube');
const clamp = (v, min, max) => Math.min(max, Math.max(min, v));

// three loose clusters: centre x, y, z, spread, colour
const clusters = [
  [-28, -26, -18, 46, '#8b6cff'],
  [32, 8, 30, 40, '#2ee6d6'],
  [-6, 36, -34, 34, '#ff4d9d'],
];
const blob = (c, spread) => clamp(c + (Math.random() + Math.random() + Math.random() - 1.5) * spread, -62, 62);

for (let n = 0; n < 42; n++) {
  const [cx, cy, cz, spread, colour] = clusters[n % 3];
  const p = document.createElement('b');
  p.style.setProperty('--x', blob(cx, spread).toFixed(0) + 'px');
  p.style.setProperty('--y', blob(cy, spread).toFixed(0) + 'px');
  p.style.setProperty('--z', blob(cz, spread).toFixed(0) + 'px');
  p.style.setProperty('--c', colour);
  cube.append(p);
}

let rx = -18, ry = 0;
let last = null;

plot.addEventListener('pointerdown', (e) => {
  last = { x: e.clientX, y: e.clientY };
  plot.setPointerCapture(e.pointerId);
  plot.classList.add('is-drag', 'is-touched');
});

plot.addEventListener('pointermove', (e) => {
  if (!last) return;
  ry += (e.clientX - last.x) * 0.5;
  rx = clamp(rx - (e.clientY - last.y) * 0.4, -80, 80);
  last = { x: e.clientX, y: e.clientY };
  plot.style.setProperty('--rx', rx + 'deg');
  plot.style.setProperty('--ry', ry + 'deg');
});

const up = () => {
  last = null;
  plot.classList.remove('is-drag');
};
plot.addEventListener('pointerup', up);
plot.addEventListener('pointercancel', up);

Sass source 142 lines

@use '../mixins' as *;

// Three nested rotations:  tilt  rotateX(--rx)      set by dragging up/down
//                          spin  rotateY(0 → 360)   CSS animation, paused while dragging
//                          box   rotateY(--ry)      set by dragging sideways
// A point undoes all three, innermost first, so it always faces the camera:
//   rotateY(-ry)  static, in `transform`
//   rotateY(-spin) animated, on the separate `rotate` property
//   rotateX(-rx)  static, in `transform`
// Both Y turns share one axis, so their order does not matter; that is what lets the animated
// part live in its own property while the variable parts stay out of the keyframes.
$size: 140px;
$spin: 28s;

.d-scatter {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  perspective: 700px;
  cursor: grab;
  touch-action: none; // let the finger drag the plot instead of scrolling the page
  user-select: none;

  &.is-drag {
    cursor: grabbing;
  }

  &__tilt {
    position: relative;
    width: $size;
    height: $size;
    margin-top: -14px; // leave room for the hint
    transform-style: preserve-3d;
    transform: scale3d(0.92, 0.92, 0.92) rotateX(var(--rx, -18deg));
  }

  &__spin {
    position: absolute;
    inset: 0;
    transform-style: preserve-3d;
    animation: d-scatter-spin $spin linear infinite;
  }

  &__box {
    position: absolute;
    inset: 0;
    transform-style: preserve-3d;
    transform: rotateY(var(--ry, 0deg));
    @include cube-faces($size);

    // wireframe walls with a faint grid
    u {
      position: absolute;
      inset: 0;
      border: 1px solid color-mix(in srgb, var(--text) 22%, transparent);
      background:
        linear-gradient(90deg, color-mix(in srgb, var(--text) 7%, transparent) 1px, transparent 1px) 0 0 / 35px 35px,
        linear-gradient(color-mix(in srgb, var(--text) 7%, transparent) 1px, transparent 1px) 0 0 / 35px 35px,
        color-mix(in srgb, var(--accent) 4%, transparent);
    }

    // axes from the corner (-70, 70, -70): x to the right, y up, z toward the front
    s {
      position: absolute;
      left: 0;
      top: 100%;
      width: $size + 8px;
      height: 2px;
      background: color-mix(in srgb, var(--text) 55%, transparent);
      transform-origin: 0 50%;
      transform: translateZ($size * -0.5);
    }

    s:nth-of-type(2) {
      transform: translateZ($size * -0.5) rotateZ(-90deg);
    }

    s:nth-of-type(3) {
      transform: translateZ($size * -0.5) rotateY(-90deg);
    }

    // points (added by JS) and axis labels share the billboard
    b {
      position: absolute;
      top: calc(50% - 5px);
      left: calc(50% - 5px);
      width: 10px;
      height: 10px;
      border-radius: 50%;
      background: radial-gradient(circle at 35% 30%, #fff 0 8%, var(--c) 50%, color-mix(in srgb, var(--c) 45%, #000));
      box-shadow: 0 0 8px color-mix(in srgb, var(--c) 60%, transparent);
      translate: var(--x) var(--y) var(--z);
      transform: rotateY(calc(var(--ry, 0deg) * -1)) rotateX(calc(var(--rx, -18deg) * -1));
      animation: d-scatter-face $spin linear infinite;
    }

    .is-label {
      display: grid;
      place-items: center;
      background: none;
      box-shadow: none;
      color: var(--muted);
      font: 700 12px/1 var(--mono);
    }
  }

  // Dragging stops the CSS spin; box and points pause together, so they stay in step.
  &.is-drag :is(.d-scatter__spin, b) {
    animation-play-state: paused;
  }

  small {
    position: absolute;
    inset: auto 0 10px;
    color: var(--muted);
    font-size: 12px;
    text-align: center;
    pointer-events: none;
    transition: opacity 0.4s;
  }

  &.is-touched small {
    opacity: 0;
  }
}

@keyframes d-scatter-spin {
  to {
    transform: rotateY(360deg);
  }
}

@keyframes d-scatter-face {
  from {
    rotate: y 0deg;
  }

  to {
    rotate: y -360deg;
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.