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 "Scroll spiral" for my site using the Enrichr Library element at https://enrichr.dev/elements/scroll-spiral.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/scroll-spiral.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
- count: 10
- radius: 150
- pitch: 34
- twist: 0.7
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/scroll-spiral.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-ssp">
<div class="ef-ssp__stage" tabindex="0" role="group" aria-label="Spiral gallery. Scroll, drag, or use the arrow keys to turn it.">
<div class="ef-ssp__helix"></div>
<div class="ef-ssp__hud" aria-live="polite"></div>
</div>
</div>CSS
/* Enrichr Library · https://enrichr.dev */
.ef-ssp { width: 100%; }
.ef-ssp__stage { position: relative; box-sizing: border-box; width: 100%; height: 24rem; border-radius: 1rem; overflow: hidden; background: radial-gradient(ellipse at 50% 40%, #0a6b46 0%, #005236 38%, #0c1e18 100%); color: #fbf8f1; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; perspective: 900px; perspective-origin: 50% 45%; cursor: grab; touch-action: pan-y; user-select: none; -webkit-user-select: none; outline: none; }
.ef-ssp__stage.is-drag { cursor: grabbing; }
.ef-ssp__stage:focus-visible { box-shadow: inset 0 0 0 3px var(--ef-acc, #e6ff55); }
.ef-ssp__helix { position: absolute; inset: 0; transform-style: preserve-3d; }
.ef-ssp__item { position: absolute; left: 50%; top: 50%; width: 6.4rem; height: 8rem; margin: -4rem 0 0 -3.2rem; border-radius: 0.8rem; display: grid; place-items: center; overflow: hidden; box-shadow: 0 14px 26px -12px rgba(0, 0, 0, 0.6); will-change: transform, opacity; backface-visibility: visible; }
.ef-ssp__item svg { width: 62%; height: 62%; display: block; }
.ef-ssp__no { position: absolute; top: 0.5rem; left: 0.6rem; font-size: 0.62rem; font-weight: 800; letter-spacing: 0.14em; opacity: 0.8; }
.ef-ssp__hud { position: absolute; left: 0; right: 0; bottom: 0.9rem; text-align: center; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase; pointer-events: none; text-shadow: 0 2px 8px rgba(12, 30, 24, 0.7); }JavaScript
// Enrichr Library · https://enrichr.dev
// Scroll spiral: tiles sit on a helix. Wheel, drag or arrow keys turn it; each tile's position, scale and opacity are recomputed from its
// angle, so the front tile is big and bright and the ones winding away shrink and fade. Motion eases toward a target and snaps to the nearest
// tile when input stops. Input is listened for on the element itself (not window). Reduced motion moves in instant steps.
// An original rebuild from the general idea of a scroll-driven spiral gallery.
// mount(root, { accent, count (5-16), radius (px), pitch (px per tile), twist (radians per tile) })
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var stage = root.querySelector(".ef-ssp__stage");
var helix = root.querySelector(".ef-ssp__helix");
var hud = root.querySelector(".ef-ssp__hud");
var opts = {}, offs = [], items = [], pos = 0, tgt = 0, raf = 0, snapT = 0, lastActive = -1, drag = null;
var NS = "http://www.w3.org/2000/svg";
var PIECES = [
{ vb: "1513.7 1453.9 322.3 322.3", 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" },
{ vb: "1423.36 860.8 503.07 322.3", 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" },
{ vb: "1513.7 166.86 322.3 503.06", 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" }
];
var NAMES = ["Fold", "Weave", "Lattice", "Bloom", "Anchor", "Tessera", "Hinge", "Canopy", "Relay", "Grove", "Strand", "Keystone", "Ridge", "Mosaic", "Thread", "Orbit"];
function listen(el, ev, fn, op) { el.addEventListener(ev, fn, op); offs.push(function () { el.removeEventListener(ev, fn, op); }); }
function colours(i) {
var a = opts.accent;
var set = [["#005236", a], [a, "#005236"], ["#0a6b46", "#fbf8f1"], ["#fbf8f1", "#2f7d5b"], ["#c6f26b", "#0c1e18"], ["#2f7d5b", "#fbf8f1"]];
return set[i % set.length];
}
function build() {
helix.textContent = "";
items = [];
for (var i = 0; i < opts.count; i++) {
var c = colours(i), p = PIECES[i % 3];
var el = document.createElement("div");
el.className = "ef-ssp__item";
el.style.background = c[0];
el.style.color = c[1];
var svg = document.createElementNS(NS, "svg");
svg.setAttribute("viewBox", p.vb);
svg.setAttribute("aria-hidden", "true");
var path = document.createElementNS(NS, "path");
path.setAttribute("fill", "currentColor");
path.setAttribute("d", p.d);
svg.appendChild(path);
var no = document.createElement("span");
no.className = "ef-ssp__no";
no.textContent = String(i + 1).padStart(2, "0");
el.appendChild(svg); el.appendChild(no);
helix.appendChild(el);
items.push(el);
}
lastActive = -1;
render();
}
function render() {
var n = items.length, half = n / 2;
for (var i = 0; i < n; i++) {
var t = ((i - pos + half) % n + n) % n - half;
var ang = t * opts.twist;
var front = (Math.cos(ang) + 1) / 2;
var s = 0.5 + 0.55 * front;
var edge = 1 - Math.min(1, Math.max(0, (Math.abs(t) - (half - 1.6)) / 1.6));
var op = (0.22 + 0.78 * front) * edge;
items[i].style.transform = "translate3d(" + (Math.sin(ang) * opts.radius).toFixed(1) + "px," + (t * opts.pitch).toFixed(1) + "px," + ((Math.cos(ang) - 1) * opts.radius).toFixed(1) + "px) rotateY(" + ang.toFixed(3) + "rad) scale(" + s.toFixed(3) + ")";
items[i].style.opacity = op.toFixed(3);
items[i].style.zIndex = String(Math.round(front * 100));
}
var act = ((Math.round(pos) % n) + n) % n;
if (act !== lastActive) {
lastActive = act;
hud.textContent = String(act + 1).padStart(2, "0") + " / " + String(n).padStart(2, "0") + " · " + NAMES[act % NAMES.length];
}
}
function tick() {
raf = 0;
var d = tgt - pos;
if (Math.abs(d) < 0.0015) { pos = tgt; render(); return; }
pos += d * 0.14;
render();
raf = requestAnimationFrame(tick);
}
function kick() {
if (reduce) { pos = tgt; render(); return; }
if (!raf) raf = requestAnimationFrame(tick);
}
function snapLater() {
clearTimeout(snapT);
snapT = setTimeout(function () { tgt = Math.round(tgt); kick(); }, 140);
}
function step(d) { clearTimeout(snapT); tgt = Math.round(tgt) + d; kick(); }
function onWheel(e) {
e.preventDefault();
var k = e.deltaMode === 1 ? 16 : 1;
var d = (Math.abs(e.deltaY) >= Math.abs(e.deltaX) ? e.deltaY : e.deltaX) * k;
tgt += d * 0.0045;
kick(); snapLater();
}
function onDown(e) {
if (e.button !== undefined && e.button !== 0) return;
drag = { x: e.clientX, y: e.clientY, id: e.pointerId };
stage.classList.add("is-drag");
try { stage.setPointerCapture(e.pointerId); } catch (x) {}
clearTimeout(snapT);
}
function onMove(e) {
if (!drag) return;
var dx = e.clientX - drag.x, dy = e.clientY - drag.y;
drag.x = e.clientX; drag.y = e.clientY;
tgt -= (Math.abs(dy) > Math.abs(dx) ? dy : -dx) * 0.012;
kick();
}
function onUp(e) {
if (!drag) return;
drag = null;
stage.classList.remove("is-drag");
try { stage.releasePointerCapture(e.pointerId); } catch (x) {}
snapLater();
}
function onKey(e) {
var k = e.key;
if (k === "ArrowRight" || k === "ArrowDown") { e.preventDefault(); step(1); }
else if (k === "ArrowLeft" || k === "ArrowUp") { e.preventDefault(); step(-1); }
}
listen(stage, "wheel", onWheel, { passive: false });
listen(stage, "pointerdown", onDown);
listen(stage, "pointermove", onMove);
listen(stage, "pointerup", onUp);
listen(stage, "pointercancel", onUp);
listen(stage, "keydown", onKey);
function update(next) {
var prev = opts;
opts = Object.assign({ accent: "#e6ff55", count: 10, radius: 150, pitch: 34, twist: 0.7 }, opts, next || {});
opts.count = Math.max(5, Math.min(16, Math.round(opts.count)));
stage.style.setProperty("--ef-acc", opts.accent);
if (prev.count !== opts.count || prev.accent !== opts.accent) build(); else render();
}
update(o);
return {
update: update,
destroy: function () {
cancelAnimationFrame(raf); raf = 0; clearTimeout(snapT);
offs.forEach(function (f) { f(); }); offs = [];
}
};
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"accent":"#e6ff55","count":10,"radius":150,"pitch":34,"twist":0.7});How it works
Each tile has a position along the helix, and one number, the scroll position, shifts all of them. For every tile the script works out its offset from the front, wraps it so the list loops, and turns that into an angle. The angle gives the tile's x and depth on a circle, its offset gives the vertical rise, and the front-facing amount drives the scale and opacity, so the tile in front is large and bright while the ones winding away shrink and fade. Wheel and drag add to a target position, a requestAnimationFrame loop eases toward it, and a short timer after input stops snaps the target to the nearest whole tile. The stage uses CSS perspective and the helix uses preserve-3d.
Accessibility and performance
The stage is focusable and the arrow keys step one tile at a time. The current tile and total are written to an aria-live label. The wheel is captured only while the pointer is over the element. Reduced motion removes the easing, so each input jumps straight to the target tile. The tiles are decorative stand-ins, so replace them with real links or buttons if the items are interactive.
Per frame it writes one transform and one opacity for each tile, up to 16. The loop runs only while the position is still moving and stops once it settles. No layout reads happen in the loop.
| Section | Gallery |
|---|---|
| Techniques | CSS 3D, Scroll-driven, Pointer interaction |
| Stack | Vanilla JS, CSS |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| Inspired by | ScrollSpiral by Codrops on github.com. 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 does the wheel not scroll my page?
The element captures the wheel while the pointer is over it so it can turn the spiral. Move the pointer off it and the page scrolls normally. If you would rather never capture it, remove the wheel listener.
Can I use my own content for the tiles?
Yes. Replace the contents built in build() with your own markup. Only the transform, opacity and z-index of each tile are set by the script.
Can the spiral run the other way?
Negate the twist or flip the sign of the pitch in the code to reverse the direction of the winding.