CSS + JS Shapes & solids #controls #shape

Scroll-linked spin in CSS + JavaScript

Scroll inside the box. JS turns scroll progress into one number; CSS maps it to rotation.

  • 81 lines of CSS
  • 10 lines of HTML
  • 9 lines of JS
  • No dependencies
  • Snippet under MIT
GitHub

How it works

  1. JS turns the scroll position into one number, --p, from 0 to 1.
  2. CSS does the mapping: rotateY(calc(var(--p) * 720deg)). Change the feel by editing CSS only.
  3. The canvas is the scroll container: .scroller covers it (position: absolute; inset: 0) with overflow-y: auto, so a wheel or a swipe anywhere over the canvas scrolls it, and its native scrollbar is hidden. A spacer three canvases tall gives it something to scroll, and position: sticky keeps the cube in view while it passes.
  4. At rest a caption under the cube says Scroll to spin, since nothing else shows that the canvas scrolls. It fades out over the first eighth of the scroll, and the cube, which sat a little high to make room for it, eases down to the middle as it goes. Both follow --hint, worked out in CSS from --p, so scrolling back to the top brings it back.
  5. Where supported, CSS can do this alone with animation-timeline: scroll() — check browser support before relying on it; the JS version works everywhere.
  6. 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 the scroll turns it to — corner-on, where it spans its body diagonal — not for the rest pose.

Key techniques

  • scroll progress 0…1 in --p
  • position: sticky stage
  • CSS-only alternative: animation-timeline: scroll()

Copy-paste code

HTML 10 lines

<div class="scroller" tabindex="0" aria-label="Scroll to spin the cube">
  <div class="sticky">
    <div class="cube">
      <div></div><div></div><div></div>
      <div></div><div></div><div></div>
    </div>
    <p class="caption">Scroll to spin</p>
  </div>
  <div class="spacer"></div>
</div>

CSS 81 lines

/* the canvas is the scroll container: this box covers it, so a wheel anywhere over the canvas
   scrolls it */
.scroller {
  /* 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 */
  --u: 0.27vmin;
  /* the hint: 1 at the top, gone an eighth of the way down (--p is set here from JS) */
  --hint: clamp(0, 1 - var(--p, 0) * 8, 1);
  position: absolute;
  inset: 0;
  overflow-x: hidden;
  overflow-y: auto;
  overscroll-behavior: contain;
  outline: none;
  scrollbar-width: none; /* a bar against the canvas edge reads as a broken page: the caption says it */
}

.scroller::-webkit-scrollbar {
  display: none;
}

.sticky {
  position: sticky;
  top: 0;
  display: grid;
  place-items: center;
  height: 100%;
  perspective: calc(700 * var(--u));
}

.spacer {
  height: 300%;
}

/* the caption, in plain vmin like every model's: 4.5vmin text on a 5.4vmin line, 4vmin under the
   cube at rest (45vmin tall). The cube and the caption stand in one stack, centred: the cube sits
   4.7vmin high, half the gap and the line, and comes down to the middle as the caption fades */
.caption {
  position: absolute;
  inset: calc(50% + 21.8vmin) 0 auto;
  margin: 0;
  font: 500 4.5vmin/1.2 Inter, system-ui, sans-serif;
  text-align: center;
  opacity: calc(0.7 * var(--hint));
  pointer-events: none;
}

.cube {
  --s: calc(130 * var(--u));
  position: relative;
  width: var(--s);
  height: var(--s);
  transform-style: preserve-3d;
  translate: 0 calc(-4.7vmin * var(--hint));
  /* --p is scroll progress 0…1, set on .scroller from JS */
  transform:
    rotateX(calc(-20deg + var(--p, 0) * 360deg))
    rotateY(calc(var(--p, 0) * 720deg));
}

.cube > * {
  position: absolute;
  inset: 0;
  background: rgb(255 181 71 / 0.32);
  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 scroller covers the canvas, so a ring round it would be off the canvas: focus lights the
   cube instead, and the arrow keys and Page Up / Down scroll it as usual */
.scroller:focus-visible .cube > * {
  background: rgb(255 181 71 / 0.5);
  border-color: #fff;
}

JS 9 lines

const box = document.querySelector('.scroller');

function onScroll() {
  const max = box.scrollHeight - box.clientHeight;
  box.style.setProperty('--p', max > 0 ? box.scrollTop / max : 0);
}

box.addEventListener('scroll', onScroll, { passive: true });
onScroll();
Standalone snippet · plain CSS, no build step, no dependencies.