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>
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)`;
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: stickydoes, 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)
Cube.fscss is open-source, repository: github.com/fscss-ttr/cube.fscss, MIT License
Top comments (0)