DEV Community

Cover image for I made a decision cube you rotate by scrolling — pure CSS, one JS listener
FSCSS
FSCSS

Posted on

I made a decision cube you rotate by scrolling — pure CSS, one JS listener

Can't decide? Scroll a cube until it lands on an answer.

This is a small demo built on cube.fscss, the 3D cube module — but instead of a CSS @keyframes spin, this one's rotation is driven entirely by scroll position. One scroll event listener, two lines of transform math, zero animation libraries.

<script src="//cdn.jsdelivr.net/npm/fscss@1.1.24/exec.min.js" async></script>

<style>
@import((*) from cube)

body{
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  min-height: 100vh;
  overflow-x: hidden;
  background: #06f;
}

@cube-root(root)
@cube-wrapper(.cube)
@cube-face(.cube div)
@cube-face-left(.cube .f1)
@cube-face-right(.cube .f2)
@cube-face-top(.cube .f3)
@cube-face-bottom(.cube .f4)
@cube-face-front(.cube .f5)
@cube-face-back(.cube .f6)

.cube{
  @cube-size(200px)
  position: sticky;
  %4(right, left, top, bottom [: 50px;])
  transition: all 0.3s ease;
}

.container{
  overflow: scroll;
  position: relative;
  width: 300px;
  height: 300px;
}
.container .empty{
  width: 7020px;
  height: 5910px;
}
</style>

<div class="container">
  <div class="cube">
    <div class="f1">Yes</div>
    <div class="f2">No</div>
    <div class="f3">Maybe</div>
    <div class="f4">True</div>
    <div class="f5">False</div>
    <div class="f6">None</div>
  </div>
  <div class="empty"></div>
</div>

<script>
  const container = document.querySelector(".container");
  const cube = document.querySelector(".cube");

  container.addEventListener("scroll", e => {
    cube.style.transform = `rotateX(${e.target.scrollTop / 2}deg) rotateY(-${e.target.scrollLeft / 2}deg)`;
  });
</script>
Enter fullscreen mode Exit fullscreen mode

How it works

The trick is a scrollable container way bigger than the cube itself — a 7020px × 5910px empty div gives the browser somewhere to actually scroll to. .cube is position: sticky, so it stays pinned in the viewport while the container scrolls underneath it, and one listener reads scrollTop/scrollLeft on every scroll tick and maps them straight to rotateX/rotateY on the cube:

cube.style.transform = `rotateX(${scrollTop / 2}deg) rotateY(-${scrollLeft / 2}deg)`;
Enter fullscreen mode Exit fullscreen mode

Divide by 2 to slow the rotation relative to scroll distance — tune that number to make it feel slower/faster. Everything else — face geometry, sizing, positioning — is cube.fscss doing what it already does; I just swapped a @keyframes animation for a JS-driven transform on the same element.

Why sticky + oversized container

I tried a few approaches before landing on this one:

  • IntersectionObserver — works, but overkill for something this simple and doesn't give you continuous fractional rotation, just enter/exit events.
  • scroll-timeline / CSS scroll-driven animations — the "correct" modern answer, but browser support is still patchy enough that I didn't want to gate the demo behind it.
  • Sticky + giant scroll track — the one above. Cheap, works everywhere position: sticky does, and the scroll listener is the only JS in the whole thing.

Try it

Swap the six words for anything — a yes/no/maybe decision cube, a mood picker, a "roll to reveal" card gimmick. The geometry mixins don't care what's inside each face.

@cube-face-left(.cube .f1)
@cube-face-right(.cube .f2)
@cube-face-top(.cube .f3)
@cube-face-bottom(.cube .f4)
@cube-face-front(.cube .f5)
@cube-face-back(.cube .f6)
Enter fullscreen mode Exit fullscreen mode

Cube.fscss is open-source, repository: github.com/fscss-ttr/cube.fscss, MIT License

Top comments (0)