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.

  • 44 lines of CSS
  • 7 lines of HTML
  • 9 lines of JS
  • No dependencies
  • MIT licensed
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. position: sticky keeps the cube in view while the tall page scrolls past.
  4. Where supported, CSS can do this alone with animation-timeline: scroll() — check browser support before relying on it; the JS version works everywhere.

Key techniques

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

Copy-paste code

HTML 7 lines

<div class="sticky">
  <div class="cube">
    <div></div><div></div><div></div>
    <div></div><div></div><div></div>
  </div>
</div>
<div class="spacer"></div>

CSS 44 lines

body {
  display: block;        /* let the page scroll normally */
  overflow: auto;
}

.sticky {
  position: sticky;
  top: 0;
  display: grid;
  place-items: center;
  height: 100vh;
  perspective: 700px;
}

.spacer {
  height: 300vh;
}

.cube {
  --s: 130px;
  position: relative;
  width: var(--s);
  height: var(--s);
  transform-style: preserve-3d;
  /* --p is scroll progress 0…1, set on <html> 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: 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 9 lines

const root = document.documentElement;

function onScroll() {
  const max = root.scrollHeight - innerHeight;
  root.style.setProperty('--p', max > 0 ? scrollY / max : 0);
}

addEventListener('scroll', onScroll, { passive: true });
onScroll();

Sass source 48 lines

@use '../mixins' as *;

.d-scrollspin {
  --p: 0; // scroll progress 0…1, written by JS
  position: absolute;
  inset: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  scrollbar-width: thin;

  // stays in view while the spacer scrolls past
  &__sticky {
    position: sticky;
    top: 0;
    display: grid;
    place-items: center;
    height: 100%;
    perspective: 600px;

    small {
      position: absolute;
      bottom: 8px;
      color: var(--muted);
      font-size: 12px;
      opacity: calc(1 - var(--p) * 4);
    }
  }

  &__spacer {
    height: 300%;
  }

  &__cube {
    --s: 90px;
    position: relative;
    width: var(--s);
    height: var(--s);
    transform-style: preserve-3d;
    transform: rotateX(calc(-20deg + var(--p) * 360deg)) rotateY(calc(var(--p) * 720deg));
    @include cube-faces(var(--s));

    i {
      position: absolute;
      inset: 0;
      @include face(var(--warm), 32%);
    }
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.