Copied!
Gallery

Fanned card deck

A looping deck of cards fanned like a hand around the one in focus, moved with buttons, arrow keys, a swipe or a click on a side card.

← 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 "Fanned card deck" for my site using the Enrichr Library element at https://enrichr.dev/elements/fanned-card-deck.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/fanned-card-deck.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:
- words: Form, Flow, Play, Feel, Move
- spread: 150
- tilt: 9
- turn: 18
- duration: 800
- accent: #e6ff55
- background: #0c1e18
- ink: #fbf8f1

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/fanned-card-deck.json.

Code

Mount it on an element that contains the markup. It returns an object with update(options) and destroy().

HTML

<div class="ef-fan">
  <div class="ef-fan__stage" role="group" aria-roledescription="carousel" aria-label="Card deck, use the left and right arrow keys" tabindex="0">
    <div class="ef-fan__deck"></div>
  </div>
  <div class="ef-fan__ctl">
    <button type="button" class="ef-fan__btn ef-fan__prev" aria-label="Previous card"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M19 12H5M11 6l-6 6 6 6" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
    <span class="ef-fan__count" aria-live="polite"></span>
    <button type="button" class="ef-fan__btn ef-fan__next" aria-label="Next card"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M5 12h14M13 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
  </div>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-fan { --ef-fan-bg: #0c1e18; --ef-fan-acc: #e6ff55; --ef-fan-ink: #fbf8f1; --ef-fan-d: 800ms; width: 100%; box-sizing: border-box; padding: 1rem 0.5rem 1.2rem; border-radius: 1rem; background: var(--ef-fan-bg); color: var(--ef-fan-ink); font-family: "Helvetica Neue", Arial, "Segoe UI", system-ui, sans-serif; }
.ef-fan__stage { position: relative; height: 21rem; perspective: 1200px; overflow: hidden; touch-action: pan-y; cursor: grab; outline: none; border-radius: 0.8rem; }
.ef-fan__stage:focus-visible { box-shadow: inset 0 0 0 3px var(--ef-fan-acc); }
.ef-fan__stage.is-drag { cursor: grabbing; }
.ef-fan__deck { position: absolute; left: 50%; top: 50%; width: 12.5rem; height: 17rem; margin: -8.5rem 0 0 -6.25rem; transform-style: preserve-3d; }
.ef-fan__card { position: absolute; inset: 0; box-sizing: border-box; padding: 1.2rem; border-radius: 1.1rem; display: flex; flex-direction: column; justify-content: space-between; background: var(--ef-fan-acc); color: var(--ef-fan-bg); box-shadow: 0 1.2rem 2.6rem rgba(0, 0, 0, 0.35); transform-origin: 50% 80%; backface-visibility: hidden; transition: transform var(--ef-fan-d) cubic-bezier(0.65, 0, 0.35, 1), opacity var(--ef-fan-d) ease; user-select: none; }
.ef-fan__card:nth-child(4n+2) { background: var(--ef-fan-ink); }
.ef-fan__card:nth-child(4n+3) { background: color-mix(in srgb, var(--ef-fan-acc) 35%, #4b8a5f); }
.ef-fan__card:nth-child(4n) { background: color-mix(in srgb, var(--ef-fan-ink) 80%, var(--ef-fan-acc)); }
.ef-fan__card.is-jump { transition: none; }
.ef-fan__lbl { font: 600 0.62rem/1 ui-monospace, "SFMono-Regular", Menlo, monospace; letter-spacing: 0.12em; }
.ef-fan__g { display: block; width: 58%; height: auto; margin: 0 auto; }
.ef-fan__w { margin: 0; font-size: 3rem; font-weight: 900; line-height: 0.9; letter-spacing: -0.07em; text-transform: uppercase; }
.ef-fan__ctl { display: flex; align-items: center; justify-content: center; gap: 1.6rem; margin-top: 0.6rem; }
.ef-fan__btn { display: grid; place-items: center; width: 3rem; height: 3rem; padding: 0; border: 1px solid color-mix(in srgb, var(--ef-fan-ink) 45%, transparent); border-radius: 50%; background: transparent; color: inherit; cursor: pointer; }
.ef-fan__btn svg { width: 1.2rem; height: 1.2rem; }
.ef-fan__btn:hover { background: var(--ef-fan-acc); color: var(--ef-fan-bg); border-color: var(--ef-fan-acc); }
.ef-fan__btn:focus-visible { outline: 3px solid var(--ef-fan-acc); outline-offset: 3px; }
.ef-fan__count { min-width: 4.5rem; text-align: center; font: 600 0.8rem/1 ui-monospace, "SFMono-Regular", Menlo, monospace; letter-spacing: 0.1em; }
@media (prefers-reduced-motion: reduce) { .ef-fan__card { transition: opacity 200ms ease; } }

JavaScript

// Enrichr Library · https://enrichr.dev
// Fanned card deck: cards spread like a hand of playing cards around the one in focus. Each card's place is its distance
// from the selected card on a loop, and that distance sets its offset, lift, tilt, turn away in 3D, size and fade. Moving
// is a CSS transition on transform; a card that wraps from one end of the fan to the other jumps instead of sweeping across.
// Buttons, arrow keys, a swipe or drag, or a click on a side card all change the selection, and the counter is announced.
// mount(root, { words (comma separated), spread (px between cards), tilt (deg per step), turn (3D turn per step), duration (ms),
//               accent, background, ink })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var box = root.querySelector(".ef-fan");
  var stage = root.querySelector(".ef-fan__stage");
  var deck = root.querySelector(".ef-fan__deck");
  var count = root.querySelector(".ef-fan__count");
  var prev = root.querySelector(".ef-fan__prev");
  var next = root.querySelector(".ef-fan__next");
  var NS = "http://www.w3.org/2000/svg";
  var GLYPHS = [
    '<path d="M50 8V92M8 50H92M20 20L80 80M80 20L20 80" stroke-width="10" stroke-linecap="round"/>',
    '<circle cx="50" cy="50" r="40" stroke-width="8"/><path d="M50 10a40 40 0 0 1 0 80z" stroke="none" fill="currentColor"/>',
    '<circle cx="50" cy="50" r="40" stroke-width="7"/><circle cx="50" cy="50" r="20" stroke-width="7"/><circle cx="50" cy="50" r="5" stroke="none" fill="currentColor"/>',
    '<path d="M22 78L78 22M36 22h42v42" stroke-width="10" stroke-linecap="round" stroke-linejoin="round"/>',
    '<path d="M50 6L94 50L50 94L6 50Z" stroke-width="8" stroke-linejoin="round"/><path d="M50 30L70 50L50 70L30 50Z" stroke="none" fill="currentColor"/>'
  ];
  var opts = {}, cards = [], sel = 0, prevD = [], scale = 1, ro = null, drag = null, offs = [];

  function build() {
    deck.textContent = "";
    cards = []; prevD = [];
    var words = String(opts.words).split(",").map(function (s) { return s.trim(); }).filter(Boolean).slice(0, 9);
    if (words.length < 2) words = words.concat(["Card", "Deck"]).slice(0, 3);
    words.forEach(function (word, i) {
      var c = document.createElement("article"); c.className = "ef-fan__card";
      c.setAttribute("role", "group"); c.setAttribute("aria-roledescription", "slide");
      c.setAttribute("aria-label", (i + 1) + " of " + words.length + ": " + word);
      var l = document.createElement("span"); l.className = "ef-fan__lbl"; l.textContent = "STUDY " + String(i + 1).padStart(2, "0");
      var g = document.createElementNS(NS, "svg"); g.setAttribute("viewBox", "0 0 100 100"); g.setAttribute("class", "ef-fan__g");
      g.setAttribute("aria-hidden", "true"); g.setAttribute("focusable", "false"); g.setAttribute("fill", "none"); g.setAttribute("stroke", "currentColor");
      g.innerHTML = GLYPHS[i % GLYPHS.length];
      var h = document.createElement("h3"); h.className = "ef-fan__w"; h.textContent = word;
      c.appendChild(l); c.appendChild(g); c.appendChild(h);
      c.addEventListener("click", function () { if (!drag || !drag.moved) go(i); });
      deck.appendChild(c); cards.push(c);
    });
    sel = Math.min(sel, cards.length - 1);
  }
  function dist(i) {
    var n = cards.length, d = ((i - sel) % n + n) % n;
    if (d > n / 2) d -= n;
    return d;
  }
  function layout() {
    var n = cards.length;
    cards.forEach(function (c, i) {
      var d = dist(i), a = Math.abs(d);
      var wrap = prevD[i] !== undefined && Math.abs(d - prevD[i]) > 1.5;
      c.classList.toggle("is-jump", wrap);
      var x = d * opts.spread * scale, y = a * 18 * scale;
      c.style.transform = reduce
        ? "translate3d(" + x + "px," + y + "px,0) scale(" + (1 - a * 0.1) + ")"
        : "translate3d(" + x.toFixed(1) + "px," + y.toFixed(1) + "px," + (-a * 60) + "px) rotate(" + (d * opts.tilt) + "deg) rotateY(" + (-d * opts.turn) + "deg) scale(" + (1 - a * 0.1).toFixed(3) + ")";
      c.style.opacity = a === 0 || a === 1 ? "1" : a === 2 ? "0.32" : "0";
      c.style.zIndex = String(20 - a);
      c.style.pointerEvents = a > 2 ? "none" : "";
      if (a === 0) c.removeAttribute("aria-hidden"); else c.setAttribute("aria-hidden", "true");
      prevD[i] = d;
    });
    count.textContent = String(sel + 1).padStart(2, "0") + " / " + String(n).padStart(2, "0");
    // Drop the jump flag after the new positions have been painted, so later moves animate again.
    cancelAnimationFrame(wrapTimer);
    wrapTimer = requestAnimationFrame(function () { wrapTimer = requestAnimationFrame(function () { cards.forEach(function (c) { c.classList.remove("is-jump"); }); }); });
  }
  var wrapTimer = 0;
  function go(i) { sel = (i % cards.length + cards.length) % cards.length; layout(); }
  function step(k) { go(sel + k); }
  function onKey(e) {
    if (e.key === "ArrowRight") { e.preventDefault(); step(1); }
    else if (e.key === "ArrowLeft") { e.preventDefault(); step(-1); }
    else if (e.key === "Home") { e.preventDefault(); go(0); }
    else if (e.key === "End") { e.preventDefault(); go(cards.length - 1); }
  }
  function down(e) { if (e.button !== undefined && e.button !== 0) return; drag = { x: e.clientX, moved: false, id: e.pointerId }; }
  function move(e) {
    if (!drag || e.pointerId !== drag.id) return;
    if (!drag.moved && Math.abs(e.clientX - drag.x) > 8) { drag.moved = true; stage.classList.add("is-drag"); try { stage.setPointerCapture(e.pointerId); } catch (err) {} }
  }
  function up(e) {
    if (!drag || e.pointerId !== drag.id) return;
    var dx = e.clientX - drag.x;
    stage.classList.remove("is-drag");
    if (drag.moved && Math.abs(dx) > 40) step(dx < 0 ? 1 : -1);
    var was = drag; setTimeout(function () { if (drag === was) drag = null; }, 0);
  }
  function measure() { scale = Math.max(0.45, Math.min(1, (stage.clientWidth || 720) / 720)); deck.style.transform = "scale(" + Math.max(0.7, scale).toFixed(3) + ")"; layout(); }
  function onPrev() { step(-1); }
  function onNext() { step(1); }
  stage.addEventListener("keydown", onKey);
  stage.addEventListener("pointerdown", down);
  stage.addEventListener("pointermove", move);
  stage.addEventListener("pointerup", up);
  stage.addEventListener("pointercancel", up);
  prev.addEventListener("click", onPrev);
  next.addEventListener("click", onNext);
  offs.push(function () {
    stage.removeEventListener("keydown", onKey); stage.removeEventListener("pointerdown", down); stage.removeEventListener("pointermove", move);
    stage.removeEventListener("pointerup", up); stage.removeEventListener("pointercancel", up);
    prev.removeEventListener("click", onPrev); next.removeEventListener("click", onNext);
  });

  function update(nextOpts) {
    opts = Object.assign({ words: "Form, Flow, Play, Feel, Move", spread: 150, tilt: 9, turn: 18, duration: 800, accent: "#e6ff55", background: "#0c1e18", ink: "#fbf8f1" }, opts, nextOpts || {});
    ["spread", "tilt", "turn", "duration"].forEach(function (k) { opts[k] = Number(opts[k]) || 0; });
    box.style.setProperty("--ef-fan-d", (reduce ? 0 : opts.duration) + "ms");
    box.style.setProperty("--ef-fan-acc", opts.accent);
    box.style.setProperty("--ef-fan-bg", opts.background);
    box.style.setProperty("--ef-fan-ink", opts.ink);
    build();
    measure();
  }
  update(o);
  if ("ResizeObserver" in window) { ro = new ResizeObserver(function () { measure(); }); ro.observe(stage); }
  return { update: update, destroy: function () { offs.forEach(function (f) { f(); }); offs = []; if (ro) ro.disconnect(); cancelAnimationFrame(wrapTimer); } };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"words":"Form, Flow, Play, Feel, Move","spread":150,"tilt":9,"turn":18,"duration":800,"accent":"#e6ff55","background":"#0c1e18","ink":"#fbf8f1"});

How it works

Each card's place is its signed distance from the selected card around the loop, so the deck has no ends. That distance sets its sideways offset, a small drop, a rotation, a turn away in 3D, a smaller scale and a fade, and the cards transition to their new transforms on a smooth in-out curve. A card that wraps from one end of the fan to the other has its transition switched off for that move, so it jumps behind the deck instead of sweeping across the front. Spacing scales with the width of the stage.

Accessibility and performance

The deck is a focusable group described as a carousel, and Left, Right, Home and End move through it. Each card is labelled with its position, side cards are hidden from assistive technology, and the counter is a polite live region. Previous and Next are real buttons with labels. With prefers-reduced-motion the cards move without 3D turns or sliding.

Moves are CSS transitions on transform and opacity, composited without layout. Nothing runs at rest; a ResizeObserver recomputes the spacing only when the stage width changes.

SectionGallery
TechniquesCarousel, CSS 3D, Pointer interaction
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
Inspired byGSAP Orbit Gallery by Enrichr on CodePen. 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. Parallax photo carouselA draggable 3D carousel of gradient photo cards with parallax inner layers, inertia and spring snap, plus arrow keys, buttons and dots. 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.

FAQ

How many cards can it hold?

Up to nine. Five to seven look best: the selected card and one on each side are fully visible and the next ones fade.

Can I put images on the cards?

Yes. Replace the glyph with an img inside .ef-fan__card and set draggable to false on it, so dragging the deck still works.

Does it autoplay?

No, on purpose. If you add a timer, pause it on hover and focus and give it a pause button.