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 "Folding layout 3D" for my site using the Enrichr Library element at https://enrichr.dev/elements/folding-layout-3d.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/folding-layout-3d.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:
- panels: 4
- foldAngle: 70
- height: 320
- word: Fold out
- manual: 0
- colorA: #005236
- colorB: #e6ff55
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/folding-layout-3d.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-fold">
<div class="ef-fold__view"><div class="ef-fold__stack"></div></div>
<label class="ef-fold__ctl"><span class="ef-fold__lab">Fold</span><input class="ef-fold__rng" type="range" min="0" max="100" step="1" value="0" aria-label="Fold amount"></label>
</div>CSS
/* Enrichr Library · https://enrichr.dev */
.ef-fold { overflow: hidden; container-type: inline-size; width: 100%; padding: 1.2rem 1rem 1rem; box-sizing: border-box; font-family: "Helvetica Neue", Arial, "Segoe UI", system-ui, sans-serif; background: var(--ef-fold-bg, #0c1e18); color: #fbf8f1; }
.ef-fold__view { position: relative; height: var(--ef-fh, 20rem); perspective: 1100px; perspective-origin: 50% 40%; }
.ef-fold__stack { position: relative; width: 100%; height: var(--ef-ph, 5rem); transform-style: preserve-3d; will-change: transform; }
.ef-fold__p { position: relative; width: 100%; height: var(--ef-ph, 5rem); transform-origin: 50% 0; transform-style: preserve-3d; will-change: transform; }
.ef-fold__p > .ef-fold__p { position: absolute; left: 0; top: 100%; }
.ef-fold__face { position: absolute; inset: 0; overflow: hidden; backface-visibility: hidden; background: var(--ef-fold-a, #005236); }
.ef-fold__art { position: absolute; left: 0; width: 100%; height: calc(var(--ef-ph, 5rem) * var(--ef-n, 4)); background: radial-gradient(circle at 78% 30%, var(--ef-fold-b, #e6ff55) 0 14%, transparent 14.5%), radial-gradient(circle at 20% 90%, rgba(255, 255, 255, 0.18) 0 28%, transparent 28.5%), linear-gradient(160deg, var(--ef-fold-a, #005236), #0c1e18 95%); }
.ef-fold__word { position: absolute; left: 6%; bottom: 8%; right: 6%; font-size: clamp(2rem, 17cqw, 7rem); font-weight: 900; line-height: 0.9; letter-spacing: -0.04em; text-transform: uppercase; color: #fbf8f1; }
.ef-fold__word em { font-style: normal; color: var(--ef-fold-b, #e6ff55); }
.ef-fold__shade { position: absolute; inset: 0; pointer-events: none; background: #000; opacity: 0; }
.ef-fold__p[data-up] .ef-fold__shade { background: #fff; }
.ef-fold__ctl { display: flex; align-items: center; gap: 0.8rem; margin-top: 0.9rem; font-size: 0.8rem; letter-spacing: 0.08em; text-transform: uppercase; }
.ef-fold__rng { flex: 1; min-width: 0; accent-color: var(--ef-fold-b, #e6ff55); height: 1.6rem; }
.ef-fold__rng:focus-visible { outline: 2px solid var(--ef-fold-b, #e6ff55); outline-offset: 3px; }
.ef-fold__sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) { .ef-fold__stack, .ef-fold__p { transition: none !important; } }JavaScript
// Enrichr Library · https://enrichr.dev
// Folding layout 3D: a stack of panels hinged to each other like a concertina. Each panel is nested inside the one above
// (transform-origin at the hinge), so rotateX on the whole chain folds the stack in CSS 3D. Fold amount follows scroll
// (flat in the middle of the screen, folded as it enters and leaves) or the slider under the stack.
// mount(root, { panels (2-8), foldAngle (max degrees per hinge), height (flat stack height px), word (text across the panels),
// manual (0 = follow scroll, 1-100 = fixed fold %), colorA, colorB })
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var box = root.querySelector(".ef-fold");
var view = root.querySelector(".ef-fold__view");
var stack = root.querySelector(".ef-fold__stack");
var rng = root.querySelector(".ef-fold__rng");
var opts = {}, panels = [], raf = 0, visible = true, io = null;
var fold = 0, target = 0, lastP = -1, held = false, lastDrawn = -1;
function render() {
stack.textContent = "";
var n = Math.max(2, Math.min(8, Math.round(opts.panels)));
box.style.setProperty("--ef-n", n);
box.style.setProperty("--ef-fh", opts.height + "px");
box.style.setProperty("--ef-ph", (opts.height / n).toFixed(2) + "px");
box.style.setProperty("--ef-fold-a", opts.colorA);
box.style.setProperty("--ef-fold-b", opts.colorB);
var parent = stack;
panels = [];
for (var i = 0; i < n; i++) {
var p = document.createElement("div");
p.className = "ef-fold__p";
if (i % 2) p.setAttribute("data-up", "");
var face = document.createElement("div"); face.className = "ef-fold__face"; face.setAttribute("aria-hidden", "true");
var art = document.createElement("div"); art.className = "ef-fold__art"; art.style.top = "calc(var(--ef-ph) * " + (-i) + ")";
var w = document.createElement("div"); w.className = "ef-fold__word";
var words = String(opts.word).split(/\s+/);
w.textContent = "";
words.forEach(function (t, k) {
if (k) w.appendChild(document.createElement("br"));
var s = document.createElement(k === words.length - 1 && words.length > 1 ? "em" : "span"); s.textContent = t; w.appendChild(s);
});
art.appendChild(w);
var sh = document.createElement("div"); sh.className = "ef-fold__shade";
face.appendChild(art); face.appendChild(sh); p.appendChild(face);
parent.appendChild(p);
panels.push({ el: p, shade: sh });
parent = p;
}
var sr = document.createElement("span"); sr.className = "ef-fold__sr"; sr.textContent = opts.word;
stack.appendChild(sr);
lastDrawn = -1;
apply(fold);
}
function apply(f) {
if (Math.abs(f - lastDrawn) < 0.0005) return;
lastDrawn = f;
var n = panels.length, th = f * Math.min(85, opts.foldAngle) * Math.PI / 180, deg = th * 180 / Math.PI;
panels.forEach(function (p, i) {
var rel = i === 0 ? deg : (i % 2 ? -2 * deg : 2 * deg);
p.el.style.transform = "rotateX(" + rel.toFixed(2) + "deg)";
var s = Math.sin(th);
p.shade.style.opacity = (i % 2 ? 0.16 * s : 0.5 * s).toFixed(3);
});
var flat = opts.height, proj = flat * Math.cos(th);
stack.style.transform = "translateY(" + ((flat - proj) / 2).toFixed(1) + "px)";
rng.value = String(Math.round(f * 100));
rng.setAttribute("aria-valuetext", Math.round(f * 100) + "% folded");
}
function scrollFold() {
if (opts.manual > 0) return opts.manual / 100;
var r = box.getBoundingClientRect(), vh = window.innerHeight || 800;
var c = (r.top + r.height / 2) / vh;
var d = Math.abs(c - 0.5) - 0.1;
var t = Math.min(1, Math.max(0, d / 0.42));
return t * t * (3 - 2 * t);
}
function tick() {
raf = requestAnimationFrame(tick);
if (!visible) return;
var s = window.scrollY;
if (Math.abs(s - lastP) > 0.5) { if (lastP >= 0) held = false; lastP = s; }
if (!held && !reduce) target = scrollFold();
fold += (target - fold) * (reduce ? 1 : 0.14);
if (Math.abs(target - fold) < 0.001) fold = target;
apply(fold);
}
function onInput() { held = true; target = Number(rng.value) / 100; fold = target; apply(fold); }
function update(next) {
opts = Object.assign({ panels: 4, foldAngle: 70, height: 320, word: "Fold out", manual: 0, colorA: "#005236", colorB: "#e6ff55" }, opts, next || {});
["panels", "foldAngle", "height", "manual"].forEach(function (k) { opts[k] = Number(opts[k]); });
held = false;
target = reduce ? (opts.manual > 0 ? opts.manual / 100 : 0) : scrollFold();
fold = target;
render();
}
rng.addEventListener("input", onInput);
update(o);
if (!reduce && "IntersectionObserver" in window) { io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; }); io.observe(box); }
raf = requestAnimationFrame(tick);
return { update: update, destroy: function () { cancelAnimationFrame(raf); if (io) io.disconnect(); rng.removeEventListener("input", onInput); } };
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"panels":4,"foldAngle":70,"height":320,"word":"Fold out","manual":0,"colorA":"#005236","colorB":"#e6ff55"});How it works
Each panel is nested inside the one above it, positioned at the top of the parent's bottom edge, with its transform origin on that edge. That makes every bottom edge a hinge. Rotating the first panel by the fold angle and each child by double the angle in alternating directions gives a zigzag, so the whole chain folds like a concertina. Every panel shows a slice of one shared picture, so the art stays continuous when flat. A shade layer darkens panels that face down and lightens those that face up, and the stack is nudged so it stays centred as it shortens. Scroll position sets the fold amount, flat in the middle of the screen and folded at the edges, and the slider overrides it until the page is scrolled again.
Accessibility and performance
The art is decorative, and the text is also present once as hidden text so it is read a single time. The slider is a native range input with a label and a spoken value, so it works with the keyboard and screen readers. With prefers-reduced-motion the stack stays flat and does not animate on scroll, while the slider still folds it on request.
Only transform and opacity change, so the work stays on the compositor. Cost grows with panel count because each panel holds its own copy of the art, so keep it to about eight. One scroll read per frame while on screen, paused when off screen.
| Section | Band |
|---|---|
| Techniques | CSS 3D, Scroll-driven |
| Stack | Vanilla JS, CSS 3D |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| Inspired by | 3D Folding Layout Technique for HTML Elements by Daniel Velasquez on Codrops. 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
Why nest the panels instead of placing them side by side?
Nesting means each hinge is the bottom edge of its parent, so one rotation per panel moves everything below it correctly. Placing panels separately would need trigonometry for every position.
Does it need preserve-3d on every level?
Yes. Every panel in the chain sets transform-style: preserve-3d, otherwise the browser flattens the children and the fold looks like a plain skew.
Can I put real content in the panels?
Yes. Replace the art element with your own content of the full stack height, and offset it by minus the panel height times its index so each panel shows its own slice.