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 cards tornado helix" for my site using the Enrichr Library element at https://enrichr.dev/elements/cards-tornado-3d.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/cards-tornado-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:
- accent: #e6ff55
- count: 14
- radius: 135
- speed: 18
- rise: 18
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/cards-tornado-3d.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-tor-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-tor-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-tor-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-tor">
<div class="ef-tor__stage" tabindex="0" role="group" aria-roledescription="3D carousel" aria-label="Spinning helix of puzzle-piece cards. Drag, scroll or use the left and right arrow keys to spin it.">
<div class="ef-tor__helix" aria-hidden="true"></div>
<p class="ef-tor__hint">Drag, scroll or use arrow keys</p>
</div>
</div>CSS
/* Enrichr Library · https://enrichr.dev */
.ef-tor { width: 100%; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.ef-tor__stage { position: relative; box-sizing: border-box; width: 100%; height: 30rem; border-radius: 1rem; overflow: hidden; perspective: 900px; perspective-origin: 50% 40%; background: #0c1e18; cursor: grab; touch-action: pan-y; user-select: none; -webkit-user-select: none; outline: none; }
.ef-tor__stage.is-drag { cursor: grabbing; }
.ef-tor__stage:focus-visible { box-shadow: inset 0 0 0 2px var(--ef-acc, #e6ff55); }
.ef-tor__helix { position: absolute; left: 50%; top: 50%; width: 0; height: 0; transform-style: preserve-3d; transform: scale(var(--k, 1)) rotateX(-14deg) rotateY(var(--ry, 0deg)); will-change: transform; }
.ef-tor__card { position: absolute; left: 0; top: 0; width: 4.4rem; height: 5.8rem; margin: -2.9rem 0 0 -2.2rem; border-radius: 0.65rem; overflow: hidden; background: var(--bg, #005236); color: var(--fg, #e6ff55); box-shadow: 0 0 0 1px rgba(251, 248, 241, 0.12) inset; }
.ef-tor__art { position: absolute; left: 12%; top: 12%; width: 76%; height: 64%; }
.ef-tor__num { position: absolute; left: 0.5rem; bottom: 0.4rem; font-size: 0.62rem; font-weight: 700; letter-spacing: 0.14em; color: var(--fg, #e6ff55); opacity: 0.85; }
.ef-tor__shade { position: absolute; inset: 0; background: #0c1e18; opacity: 0; pointer-events: none; }
.ef-tor__hint { position: absolute; left: 0; right: 0; bottom: 0.8rem; margin: 0; text-align: center; font-size: 0.7rem; letter-spacing: 0.1em; text-transform: uppercase; color: #fbf8f1; opacity: 0.6; pointer-events: none; }
@media (prefers-reduced-motion: reduce) { .ef-tor__stage { cursor: default; } }JavaScript
// Enrichr Library · https://enrichr.dev
// Cards tornado 3D: puzzle-piece cards climb a helix that narrows toward the bottom and spins in 3D.
// Auto-spin plus pointer position set the speed; drag and wheel (only while the pointer is over it) add momentum; arrow keys nudge.
// Reduced motion: no spinning, every input steps the helix by one card instantly.
// mount(root, { count (6-16), radius (px), speed (deg/s), rise (px between cards), accent }) -> { update, destroy }
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var stage = root.querySelector(".ef-tor__stage");
var helix = root.querySelector(".ef-tor__helix");
var opts = {}, cards = [], offs = [], rot = 0, vel = 0, hover = 0, raf = 0, last = 0, drag = null, visible = true, over = false, wheelAt = 0, io = null, ro = null;
var PAL = [["#005236", "#e6ff55"], ["#0a6b46", "#fbf8f1"], ["#2f7d5b", "#0c1e18"], ["#c6f26b", "#005236"], ["#fbf8f1", "#0a6b46"], ["#0c1e18", "#c6f26b"]];
var PIECES = ["p1", "p4", "p8"];
function stepDeg() { return 720 / opts.count; }
function build() {
helix.textContent = "";
cards = [];
var n = opts.count;
for (var i = 0; i < n; i++) {
var t = n > 1 ? i / (n - 1) : 0;
var c = document.createElement("div");
c.className = "ef-tor__card";
var pal = PAL[i % PAL.length];
c.style.setProperty("--bg", pal[0]);
c.style.setProperty("--fg", i % 6 === 0 ? opts.accent : pal[1]);
var y = ((n - 1) / 2 - i) * opts.rise;
var r = opts.radius * (1 - 0.5 * t);
c.style.transform = "rotateY(" + (i * stepDeg()).toFixed(2) + "deg) translateY(" + y.toFixed(1) + "px) translateZ(" + r.toFixed(1) + "px)";
c.innerHTML = '<svg class="ef-tor__art" aria-hidden="true" focusable="false"><use href="#ef-tor-' + PIECES[i % 3] + '" width="100%" height="100%"/></svg><span class="ef-tor__num">' + String(i + 1).padStart(2, "0") + '</span><span class="ef-tor__shade"></span>';
helix.appendChild(c);
cards.push({ el: c, shade: c.lastChild, base: i * stepDeg() });
}
render();
}
function render() {
helix.style.setProperty("--ry", rot.toFixed(2) + "deg");
for (var i = 0; i < cards.length; i++) {
var f = Math.cos(((cards[i].base + rot) * Math.PI) / 180);
cards[i].shade.style.opacity = (0.62 * (1 - (f + 1) / 2)).toFixed(3);
}
}
function size() { helix.style.setProperty("--k", Math.max(0.5, Math.min(1.25, stage.clientWidth / 460)).toFixed(3)); }
function stepBy(dir) { rot = Math.round(rot / stepDeg() + dir) * stepDeg(); render(); }
function frame(t) {
raf = requestAnimationFrame(frame);
var dt = Math.min(0.05, (t - last) / 1000); last = t;
if (!drag) {
var target = opts.speed + (over ? hover * 60 : 0);
vel += (target - vel) * (1 - Math.exp(-dt * 2.2));
rot += vel * dt;
}
render();
}
function start() { if (!raf && !reduce && visible) { last = performance.now(); raf = requestAnimationFrame(frame); } }
function stop() { if (raf) { cancelAnimationFrame(raf); raf = 0; } }
function down(e) {
if (e.button !== undefined && e.button > 0) return;
drag = { lx: e.clientX, lt: performance.now(), v: 0, total: 0 };
stage.classList.add("is-drag");
try { stage.setPointerCapture(e.pointerId); } catch (x) {}
}
function move(e) {
var r = stage.getBoundingClientRect();
hover = ((e.clientX - r.left) / r.width - 0.5) * 2;
if (!drag) return;
var dx = e.clientX - drag.lx, now = performance.now();
drag.total += dx;
if (!reduce) {
rot += dx * 0.45;
drag.v = (dx * 0.45) / Math.max(0.008, (now - drag.lt) / 1000);
}
drag.lx = e.clientX; drag.lt = now;
render();
}
function up() {
if (!drag) return;
if (reduce) { if (Math.abs(drag.total) > 24) stepBy(drag.total > 0 ? 1 : -1); }
else vel = Math.max(-500, Math.min(500, drag.v));
drag = null;
stage.classList.remove("is-drag");
}
function wheel(e) {
e.preventDefault();
var d = Math.abs(e.deltaY) > Math.abs(e.deltaX) ? e.deltaY : e.deltaX;
if (reduce) {
var now = performance.now();
if (now - wheelAt > 220 && Math.abs(d) > 2) { wheelAt = now; stepBy(d > 0 ? 1 : -1); }
return;
}
vel = Math.max(-500, Math.min(500, vel + d * 0.6));
}
function key(e) {
var d = e.key === "ArrowRight" || e.key === "ArrowUp" ? 1 : e.key === "ArrowLeft" || e.key === "ArrowDown" ? -1 : 0;
if (!d) return;
e.preventDefault();
if (reduce) stepBy(d); else vel = Math.max(-500, Math.min(500, vel + d * 160));
}
function enter() { over = true; }
function leave() { over = false; }
function on(el, ev, fn, op) { el.addEventListener(ev, fn, op); offs.push(function () { el.removeEventListener(ev, fn, op); }); }
on(stage, "pointerdown", down);
on(stage, "pointermove", move);
on(stage, "pointerup", up);
on(stage, "pointercancel", up);
on(stage, "pointerenter", enter);
on(stage, "pointerleave", leave);
on(stage, "wheel", wheel, { passive: false });
on(stage, "keydown", key);
if (window.IntersectionObserver) {
io = new IntersectionObserver(function (en) { visible = en[0].isIntersecting; if (visible) start(); else stop(); });
io.observe(stage);
}
if (window.ResizeObserver) { ro = new ResizeObserver(size); ro.observe(stage); }
function update(next) {
var prev = opts;
opts = Object.assign({ count: 14, radius: 135, speed: 18, rise: 18, accent: "#e6ff55" }, opts, next || {});
opts.count = Math.max(6, Math.min(16, Math.round(opts.count)));
size();
if (prev.count !== opts.count || prev.radius !== opts.radius || prev.rise !== opts.rise || prev.accent !== opts.accent) build();
stage.style.setProperty("--ef-acc", opts.accent);
start();
}
update(o);
return { update: update, destroy: function () { stop(); offs.forEach(function (f) { f(); }); offs = []; if (io) io.disconnect(); if (ro) ro.disconnect(); } };
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"accent":"#e6ff55","count":14,"radius":135,"speed":18,"rise":18});How it works
Each card is placed once with rotateY, translateY and translateZ so the cards form two turns of a helix whose radius shrinks toward the bottom. Only the parent helix element moves: one rotateY value is written per frame. A velocity eases toward a target made of the auto-spin speed plus the pointer's horizontal position over the stage. Dragging adds to the rotation directly and hands its release speed to the velocity, and the wheel (listened to on the stage only, so the page scrolls normally elsewhere) and arrow keys add momentum. A dark overlay on each card is faded by the cosine of its angle so cards at the back read as farther away.
Accessibility and performance
The stage is focusable, labelled, and spins with the left and right arrow keys. With prefers-reduced-motion the auto-spin and momentum are off and each wheel notch, swipe or key press turns the helix by exactly one card instantly. The cards are decorative art and hidden from assistive tech; put real content or links in them only with a separate accessible list.
One transform write per frame on a single element plus an opacity write per card, no layout reads inside the loop. The loop pauses when the stage scrolls out of view and while the tab is hidden. Keep the card count under about 20.
| Section | Gallery |
|---|---|
| Techniques | CSS 3D, Pointer interaction |
| Stack | Vanilla JS, CSS |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| Inspired by | 3D Cards Tornado (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 the spin go the other way?
Yes. Use a negative speed in update(). Pointer position and drag still push it either way.
Will it block page scrolling?
No. Wheel is captured only while the pointer is over the stage, and touch uses pan-y so vertical swipes still scroll the page.
Can I put images or links in the cards?
Yes, replace the SVG in build() with your own markup. Cards on the back of the helix are visible but dimmed, so avoid tiny interactive targets there.