Copied!
Gallery

3D cards tornado helix

Puzzle-piece cards wind up a funnel-shaped helix that spins in 3D, with speed set by auto-spin, pointer position, drag momentum and wheel.

← Library

Element · Vanilla JS + CSSUpdated November 6, 2026

3DElementVanilla JS + CSS

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.

SectionGallery
TechniquesCSS 3D, Pointer interaction
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
Inspired by3D 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.

3D ring carouselCards on a CSS 3D ring that rotates one step at a time, with buttons, arrow keys and autoplay. Card hover tilt with glareA grid of cards that tilt in 3D toward the pointer with a moving glare highlight and a lifted shadow, all driven by CSS variables. Parallax photo carouselA draggable 3D carousel of gradient photo cards with parallax inner layers, inertia and spring snap, plus arrow keys, buttons and dots.

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.