Live preview needs JavaScript. The code below works without any dependencies.
Prompt for your AI assistant
Paste this into Claude, Cursor or ChatGPT. The settings update as you move the controls above.
Build "3D cube page transition" for my site using the Enrichr Library element at https://enrichr.dev/elements/cube-page-transition.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/cube-page-transition.json (markup, CSS, JavaScript, parameters).
Requirements: vanilla JavaScript and CSS, no new dependencies; keep the behaviour of the source unchanged; respect prefers-reduced-motion; work on touch screens; keep it accessible.
Settings:
- accent: #e6ff55
- duration: 900
- dip: 140
- tilt: 6
- perspective: 900
Verify: reduced motion shows no movement, it works at 375px wide, there are no console errors, and scrolling stays smooth.The same spec as JSON, for tools that read it directly: /elements/cube-page-transition.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false"><defs>
<symbol id="ef-cube-p1" viewBox="1513.7 1453.9 322.3 322.3">
<path fill="currentColor" d="M1821 1761.2h-292.3v-111.6c0-8 6.5-14.5 14.5-14.5 4.2 0 8.2 1.9 11 5.1 7.8 8.6 19.7 13.6 32.7 11.9 16.8-2.3 30.1-16 32-32.7 2.5-22.6-15.1-41.6-37.1-41.6-11 0-20.9 4.7-27.7 12.3-2.8 3.1-6.7 4.9-10.9 4.9-8 0-14.5-6.4-14.5-14.4v-111.7h292.3v111.7c0 8-6.4 14.4-14.5 14.4-4.2 0-8.1-1.8-10.9-5-7.9-8.6-19.7-13.6-32.7-11.9-16.8 2.2-30.1 15.9-32 32.7-2.5 22.6 15.1 41.6 37.1 41.6 11 0 20.8-4.8 27.7-12.3 2.8-3.1 6.7-5 10.8-5 8.1 0 14.5 6.5 14.5 14.5z"/>
</symbol>
<symbol id="ef-cube-p4" viewBox="1423.36 860.8 503.07 322.3">
<path fill="currentColor" d="M1821 875.8v111.6c0 8 6.5 14.5 14.5 14.5 4.2 0 8.1-1.9 10.9-5 6.8-7.5 16.7-12.3 27.7-12.3 22 0 39.6 19 37.1 41.6-1.9 16.7-15.2 30.5-32 32.7-13 1.7-24.8-3.3-32.7-11.9-2.8-3.2-6.8-5-11-5-8 0-14.5 6.4-14.5 14.4v111.7h-111.6c-8 0-14.5-6.5-14.5-14.5 0-4.2 1.9-8.2 5-11 8.7-7.9 13.7-19.7 12-32.7-2.3-16.8-16-30.1-32.8-32-22.5-2.5-41.5 15.1-41.5 37.1 0 11 4.7 20.9 12.3 27.7 3.1 2.8 4.9 6.7 4.9 10.8v0.1c0 8-6.4 14.5-14.5 14.5h-111.6v-111.7c0-8-6.4-14.4-14.5-14.4-4.1 0-8 1.8-10.8 4.9-6.9 7.6-16.7 12.3-27.7 12.3-22 0-39.6-19-37.1-41.6 1.9-16.7 15.2-30.4 32-32.7 13-1.7 24.8 3.3 32.7 11.9 2.8 3.2 6.7 5.1 10.9 5.1 8.1 0 14.5-6.5 14.5-14.5v-111.6z"/>
</symbol>
<symbol id="ef-cube-p8" viewBox="1513.7 166.86 322.3 503.06">
<path fill="currentColor" d="M1528.7 564.5v-111.6c0-8 6.5-14.5 14.5-14.5 4.2 0 8.2 1.9 11 5 7.8 8.7 19.7 13.7 32.7 11.9 16.8-2.2 30.1-15.9 32-32.7 2.5-22.5-15.1-41.5-37.1-41.5-11 0-20.9 4.7-27.7 12.3-2.8 3.1-6.7 4.9-10.9 4.9-8 0-14.5-6.5-14.5-14.5v-111.6h111.6c8.1 0 14.5-6.5 14.5-14.5 0-4.1-1.8-8.1-4.9-10.9-7.6-6.8-12.3-16.6-12.3-27.6 0-22 19-39.6 41.5-37.1 16.8 1.8 30.5 15.2 32.8 32 1.7 12.9-3.3 24.8-12 32.7-3.1 2.8-5 6.7-5 10.9 0 8 6.5 14.5 14.5 14.5h111.6v111.6c0 8-6.4 14.5-14.5 14.5-4.2 0-8.1-1.9-10.9-5-7.9-8.7-19.7-13.6-32.7-11.9-16.8 2.2-30.1 15.9-32 32.7-2.5 22.5 15.1 41.6 37.1 41.6 11 0 20.8-4.8 27.7-12.3 2.8-3.1 6.7-5 10.8-5 8.1 0 14.5 6.5 14.5 14.5v111.6h-111.6c-8 0-14.5 6.5-14.5 14.5 0 4.2 1.9 8.1 5 10.9 7.5 6.8 12.3 16.7 12.3 27.7 0 22-19 39.5-41.6 37.1-16.8-1.9-30.5-15.3-32.7-32-1.7-13 3.3-24.9 11.9-32.7 3.1-2.9 5-6.8 5-11 0-8-6.4-14.5-14.5-14.5z"/>
</symbol></defs></svg>
<div class="ef-cube">
<div class="ef-cube__stage" tabindex="0" role="group" aria-roledescription="carousel" aria-label="Four scenes on a rotating cube. Use the buttons or left and right arrow keys.">
<div class="ef-cube__cube"></div>
<div class="ef-cube__ui">
<button type="button" class="ef-cube__btn" data-dir="-1" aria-label="Previous scene">←</button>
<div class="ef-cube__dots"></div>
<button type="button" class="ef-cube__btn" data-dir="1" aria-label="Next scene">→</button>
</div>
<p class="ef-cube__live" aria-live="polite"></p>
</div>
</div>CSS
/* Enrichr Library · https://enrichr.dev */
.ef-cube { width: 100%; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.ef-cube__stage { position: relative; box-sizing: border-box; width: 100%; height: 30rem; border-radius: 1rem; overflow: hidden; background: #0c1e18; perspective: var(--persp, 900px); touch-action: pan-y; outline: none; }
.ef-cube__stage:focus-visible { box-shadow: inset 0 0 0 2px var(--ef-acc, #e6ff55); }
.ef-cube__cube { position: absolute; inset: 0; transform-style: preserve-3d; will-change: transform; }
.ef-cube__face { position: absolute; inset: 0; display: flex; flex-direction: column; justify-content: flex-end; padding: 1.6rem 1.6rem 4.6rem; box-sizing: border-box; overflow: hidden; backface-visibility: hidden; -webkit-backface-visibility: hidden; background: var(--bg, #005236); color: var(--fg, #fbf8f1); }
.ef-cube__art { position: absolute; right: 6%; top: 8%; width: 58%; height: 58%; color: var(--art, #e6ff55); opacity: 0.95; }
.ef-cube__no { position: absolute; left: 1.6rem; top: 1.4rem; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase; opacity: 0.8; }
.ef-cube__t { position: relative; margin: 0; font-size: clamp(1.8rem, 6vw, 2.8rem); line-height: 1; font-weight: 800; letter-spacing: -0.03em; }
.ef-cube__s { position: relative; margin: 0.5rem 0 0; max-width: 20rem; font-size: 0.9rem; line-height: 1.4; opacity: 0.85; }
.ef-cube__ui { position: absolute; left: 0; right: 0; bottom: 1rem; display: flex; align-items: center; justify-content: center; gap: 0.9rem; }
.ef-cube__btn { width: 2.5rem; height: 2.5rem; border-radius: 50%; border: 1.5px solid #fbf8f1; background: rgba(12, 30, 24, 0.55); color: #fbf8f1; font-size: 1.05rem; line-height: 1; cursor: pointer; transition: background 200ms, color 200ms; }
.ef-cube__btn:hover { background: var(--ef-acc, #e6ff55); color: #0c1e18; border-color: var(--ef-acc, #e6ff55); }
.ef-cube__btn:focus-visible, .ef-cube__dot:focus-visible { outline: 2px solid var(--ef-acc, #e6ff55); outline-offset: 2px; }
.ef-cube__dots { display: flex; gap: 0.5rem; padding: 0.7rem 0.9rem; border-radius: 999px; background: rgba(12, 30, 24, 0.72); }
.ef-cube__dot { width: 0.7rem; height: 0.7rem; padding: 0; border-radius: 50%; border: 1.5px solid #fbf8f1; background: transparent; cursor: pointer; transition: background 200ms, transform 200ms; }
.ef-cube__dot[aria-current="true"] { background: var(--ef-acc, #e6ff55); border-color: var(--ef-acc, #e6ff55); transform: scale(1.2); }
.ef-cube__live { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) { .ef-cube__btn, .ef-cube__dot { transition: none; } }JavaScript
// Enrichr Library · https://enrichr.dev
// Cube page transition: four scenes sit on the faces of a 3D cube; buttons, dots, arrow keys, swipe or wheel turn it to the next scene.
// The cube dips back and tilts a little mid-turn so it reads like a page change. Reduced motion: scenes switch instantly with no turn.
// mount(root, { duration (ms), dip (px pushed back mid-turn), tilt (deg), perspective (px), accent }) -> { update, destroy, go }
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var stage = root.querySelector(".ef-cube__stage");
var cube = root.querySelector(".ef-cube__cube");
var dotsBox = root.querySelector(".ef-cube__dots");
var live = root.querySelector(".ef-cube__live");
var opts = {}, offs = [], faces = [], dots = [], angle = 0, step = 0, raf = 0, wheelAt = 0, down = null, ro = null;
var SCENES = [
{ t: "Match", s: "Pieces that fit are easier to find than you think.", bg: "#005236", fg: "#fbf8f1", art: "#e6ff55", p: "p1" },
{ t: "Connect", s: "Wide pieces bridge two ideas into one clean shape.", bg: "#c6f26b", fg: "#0c1e18", art: "#005236", p: "p4" },
{ t: "Build", s: "Tall pieces stack. Each one carries the next.", bg: "#0a6b46", fg: "#fbf8f1", art: "#fbf8f1", p: "p8" },
{ t: "Finish", s: "The last turn lands where the first one began.", bg: "#fbf8f1", fg: "#0c1e18", art: "#2f7d5b", p: "p1" }
];
var half = 300, w = 600;
function build() {
cube.textContent = ""; dotsBox.textContent = ""; faces = []; dots = [];
SCENES.forEach(function (sc, i) {
var f = document.createElement("section");
f.className = "ef-cube__face";
f.setAttribute("aria-label", "Scene " + (i + 1) + " of 4: " + sc.t);
f.style.setProperty("--bg", sc.bg); f.style.setProperty("--fg", sc.fg); f.style.setProperty("--art", i === 0 ? opts.accent : sc.art);
f.innerHTML = '<span class="ef-cube__no">0' + (i + 1) + ' / 04</span><svg class="ef-cube__art" aria-hidden="true" focusable="false"><use href="#ef-cube-' + sc.p + '" width="100%" height="100%"/></svg><h3 class="ef-cube__t">' + sc.t + '</h3><p class="ef-cube__s">' + sc.s + '</p>';
cube.appendChild(f); faces.push(f);
var d = document.createElement("button");
d.type = "button"; d.className = "ef-cube__dot"; d.setAttribute("aria-label", "Go to scene " + (i + 1) + ": " + sc.t);
var h = function () { go(i); };
d.addEventListener("click", h);
offs.push(function () { d.removeEventListener("click", h); });
dotsBox.appendChild(d); dots.push(d);
});
layout(); mark();
}
function layout() {
w = stage.clientWidth || 600; half = w / 2;
stage.style.setProperty("--persp", Math.round(w * opts.perspective / 600) + "px");
for (var i = 0; i < faces.length; i++) faces[i].style.transform = "rotateY(" + i * 90 + "deg) translateZ(" + half + "px)";
paint(angle, 0);
}
function paint(a, t) {
var dip = Math.sin(Math.PI * t);
cube.style.transform = "translateZ(" + (-half - dip * opts.dip).toFixed(1) + "px) rotateX(" + (dip * opts.tilt).toFixed(2) + "deg) rotateY(" + (-a).toFixed(2) + "deg)";
}
function idx() { return ((step % 4) + 4) % 4; }
function mark() {
var cur = idx();
faces.forEach(function (f, i) {
if (i === cur) { f.removeAttribute("aria-hidden"); if ("inert" in f) f.inert = false; }
else { f.setAttribute("aria-hidden", "true"); if ("inert" in f) f.inert = true; }
});
dots.forEach(function (d, i) { if (i === cur) d.setAttribute("aria-current", "true"); else d.removeAttribute("aria-current"); });
live.textContent = "Scene " + (cur + 1) + " of 4: " + SCENES[cur].t;
}
function ease(t) { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; }
function turn(toStep) {
if (raf) { cancelAnimationFrame(raf); raf = 0; }
step = toStep; mark();
var to = toStep * 90;
if (reduce || opts.duration < 30) { angle = to; paint(angle, 0); return; }
var from = angle, t0 = performance.now();
function tick(now) {
var p = Math.min(1, (now - t0) / opts.duration);
angle = from + (to - from) * ease(p);
paint(angle, p);
if (p < 1) raf = requestAnimationFrame(tick); else { raf = 0; angle = to; paint(angle, 0); }
}
raf = requestAnimationFrame(tick);
}
function go(i) {
var cur = idx(), diff = ((i - cur + 6) % 4) - 2;
if (diff === -2) diff = 2;
if (diff !== 0) turn(step + diff);
}
function by(d) { turn(step + d); }
function key(e) {
var d = e.key === "ArrowRight" || e.key === "ArrowDown" ? 1 : e.key === "ArrowLeft" || e.key === "ArrowUp" ? -1 : 0;
if (!d) return;
e.preventDefault(); by(d);
}
function btn(e) {
var b = e.target.closest ? e.target.closest(".ef-cube__btn") : null;
if (b) by(parseInt(b.getAttribute("data-dir"), 10));
}
function pd(e) { if (e.target.closest && e.target.closest("button")) return; down = { x: e.clientX, y: e.clientY }; }
function pu(e) {
if (!down) return;
var dx = e.clientX - down.x, dy = e.clientY - down.y;
down = null;
if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy)) by(dx < 0 ? 1 : -1);
}
function wheel(e) {
e.preventDefault();
var now = performance.now();
if (now - wheelAt < Math.max(400, opts.duration * 0.8)) return;
var d = Math.abs(e.deltaY) > Math.abs(e.deltaX) ? e.deltaY : e.deltaX;
if (Math.abs(d) < 4) return;
wheelAt = now; by(d > 0 ? 1 : -1);
}
function on(el, ev, fn, op) { el.addEventListener(ev, fn, op); offs.push(function () { el.removeEventListener(ev, fn, op); }); }
on(stage, "keydown", key);
on(stage, "click", btn);
on(stage, "pointerdown", pd);
on(stage, "pointerup", pu);
on(stage, "pointercancel", function () { down = null; });
on(stage, "wheel", wheel, { passive: false });
if (window.ResizeObserver) { ro = new ResizeObserver(function () { layout(); }); ro.observe(stage); }
function update(next) {
var prev = opts;
opts = Object.assign({ duration: 900, dip: 140, tilt: 6, perspective: 900, accent: "#e6ff55" }, opts, next || {});
stage.style.setProperty("--ef-acc", opts.accent);
if (!faces.length || prev.accent !== opts.accent) build(); else layout();
}
update(o);
return { update: update, go: go, destroy: function () { if (raf) cancelAnimationFrame(raf); raf = 0; offs.forEach(function (f) { f(); }); offs = []; if (ro) ro.disconnect(); } };
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"accent":"#e6ff55","duration":900,"dip":140,"tilt":6,"perspective":900});How it works
The cube fills the stage. Face i is rotated i times 90 degrees around Y and pushed out by half the stage width, so the whole cube is pushed back by the same amount to keep the front face flush with the screen. To change scene the script tweens one angle, rotating the cube by minus that angle, and adds a sine-shaped extra translateZ and rotateX so the cube shrinks away and tilts at the midpoint of the turn before settling. The angle keeps counting past 360 so next always spins the same direction, and dots take the shortest way round. Backface visibility is hidden on every face.
Accessibility and performance
Left and right arrow keys, the two buttons and the dots all change scene, and the stage has a polite live region announcing the new scene. Faces that are not showing are inert and aria-hidden so tab order and screen readers only see the current scene. With prefers-reduced-motion the scenes switch instantly with no turn.
Each turn writes one transform on the cube per frame; faces never change. The tween is a requestAnimationFrame loop that stops when the turn ends. Heavy content inside faces is the main cost, so keep scenes light or use images sized to the stage.
| Section | Full page |
|---|---|
| Techniques | CSS 3D, Pointer interaction |
| Stack | Vanilla JS, CSS |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| Inspired by | 3D Cube Page Transition (general effect, rebuilt from scratch) by Osmo on osmo.supply. Original code, written from scratch. |
Find this technique on a real site
Want to see this technique in the wild? Check any site with the free tools, then build your own version from this page.
Check which animation library a site uses · Extract a site's design tokens · Browse site breakdowns
Licence and provenance
Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.
This is original code. The technique is a common one that we studied by measuring award-winning sites with Enrichr; no third-party code, shaders, fonts or images are used.
More elements
FAQ
Can I have more than four scenes?
A cube has four side faces, so four. For more scenes use the same idea with a prism: change the face angle to 360 divided by the count and the translateZ to match.
Does the wheel hijack page scroll?
Only while the pointer is over the cube. Elsewhere the page scrolls normally.
How do I use my own scenes?
Edit the SCENES list in the script, or replace the face markup in build() with your own sections.