Copied!
Gallery

Radial cards slider

Cards fan along a circular arc like a dial; wheel, drag, arrow keys or buttons rotate it and the active card sits centred, enlarged and outlined.

← Library

Element · Vanilla JS + CSSUpdated November 6, 2026

CursorElementVanilla 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 "Radial cards slider" for my site using the Enrichr Library element at https://enrichr.dev/elements/radial-cards-slider.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/radial-cards-slider.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: 8
- radius: 330
- step: 20
- scale: 1.3

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/radial-cards-slider.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-rad-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-rad-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-rad-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-rad">
  <div class="ef-rad__stage" tabindex="0" role="group" aria-roledescription="carousel" aria-label="Cards on a circular dial. Drag, scroll or use the left and right arrow keys to rotate.">
    <div class="ef-rad__pivot"></div>
    <div class="ef-rad__cap">
      <button type="button" class="ef-rad__btn" data-dir="-1" aria-label="Previous card">&#8592;</button>
      <p class="ef-rad__name" aria-live="polite"></p>
      <button type="button" class="ef-rad__btn" data-dir="1" aria-label="Next card">&#8594;</button>
    </div>
  </div>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-rad { width: 100%; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.ef-rad__stage { position: relative; box-sizing: border-box; width: 100%; height: 30rem; border-radius: 1rem; overflow: hidden; background: #0c1e18; cursor: grab; touch-action: pan-y; user-select: none; -webkit-user-select: none; outline: none; }
.ef-rad__stage.is-drag { cursor: grabbing; }
.ef-rad__stage:focus-visible { box-shadow: inset 0 0 0 2px var(--ef-acc, #e6ff55); }
.ef-rad__pivot { position: absolute; left: 50%; top: 0; width: 0; height: 0; }
.ef-rad__card { position: absolute; left: 0; top: 0; width: 6.4rem; height: 8.6rem; padding: 0; border: 0; border-radius: 0.8rem; overflow: hidden; background: var(--bg, #005236); color: var(--fg, #e6ff55); box-shadow: 0 10px 22px -10px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(251, 248, 241, 0.12) inset; cursor: pointer; will-change: transform, opacity; transform-origin: 50% 50%; }
.ef-rad__card.is-active { box-shadow: 0 16px 30px -10px rgba(0, 0, 0, 0.8), 0 0 0 2px var(--ef-acc, #e6ff55); }
.ef-rad__art { position: absolute; left: 12%; top: 10%; width: 76%; height: 66%; pointer-events: none; }
.ef-rad__no { position: absolute; left: 0.6rem; bottom: 0.5rem; font-size: 0.66rem; font-weight: 700; letter-spacing: 0.14em; pointer-events: none; }
.ef-rad__cap { position: absolute; left: 0; right: 0; bottom: 1rem; display: flex; align-items: center; justify-content: center; gap: 1rem; color: #fbf8f1; }
.ef-rad__name { min-width: 8rem; margin: 0; text-align: center; font-size: 1rem; font-weight: 800; letter-spacing: -0.01em; }
.ef-rad__name span { display: block; margin-top: 0.15rem; font-size: 0.68rem; font-weight: 600; letter-spacing: 0.14em; opacity: 0.7; }
.ef-rad__btn { width: 2.4rem; height: 2.4rem; border-radius: 50%; border: 1.5px solid #fbf8f1; background: transparent; color: #fbf8f1; font-size: 1rem; line-height: 1; cursor: pointer; transition: background 200ms, color 200ms; }
.ef-rad__btn:hover { background: var(--ef-acc, #e6ff55); color: #0c1e18; border-color: var(--ef-acc, #e6ff55); }
.ef-rad__btn:focus-visible, .ef-rad__card:focus-visible { outline: 2px solid var(--ef-acc, #e6ff55); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { .ef-rad__btn { transition: none; } .ef-rad__stage { cursor: default; } }

JavaScript

// Enrichr Library · https://enrichr.dev
// Radial cards slider: cards fan along a circular arc like a dial. Drag, wheel (only while the pointer is over it), arrow keys or the buttons
// rotate it; the active card sits at the top centre, enlarged and outlined. The dial loops. Reduced motion: it jumps card to card with no easing.
// mount(root, { count (5-12), radius (px), step (deg between cards), scale (active size), accent }) -> { update, destroy, go }
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var stage = root.querySelector(".ef-rad__stage");
  var pivot = root.querySelector(".ef-rad__pivot");
  var nameEl = root.querySelector(".ef-rad__name");
  var opts = {}, offs = [], cardOffs = [], cards = [], pos = 0, target = 0, raf = 0, last = 0, drag = null, wheelAcc = 0, wheelAt = 0, ro = null, shown = -1;
  var PAL = [["#005236", "#e6ff55"], ["#0a6b46", "#fbf8f1"], ["#2f7d5b", "#0c1e18"], ["#c6f26b", "#005236"], ["#fbf8f1", "#0a6b46"], ["#0c1e18", "#c6f26b"]];
  var NAMES = ["Fit", "Link", "Spark", "Stack", "Weave", "Align", "Merge", "Shape", "Anchor", "Mesh", "Frame", "Click"];
  var PIECES = ["p1", "p4", "p8"];

  function wrap(d, n) { d = ((d % n) + n) % n; return d > n / 2 ? d - n : d; }
  function build() {
    cardOffs.forEach(function (f) { f(); }); cardOffs = [];
    pivot.textContent = ""; cards = [];
    for (var i = 0; i < opts.count; i++) (function (i) {
      var b = document.createElement("button");
      b.type = "button"; b.className = "ef-rad__card"; b.tabIndex = -1;
      b.setAttribute("aria-label", NAMES[i % NAMES.length] + ", card " + (i + 1) + " of " + opts.count);
      var pal = PAL[i % PAL.length];
      b.style.setProperty("--bg", pal[0]); b.style.setProperty("--fg", i % 6 === 0 ? opts.accent : pal[1]);
      b.innerHTML = '<svg class="ef-rad__art" aria-hidden="true" focusable="false"><use href="#ef-rad-' + PIECES[i % 3] + '" width="100%" height="100%"/></svg><span class="ef-rad__no">' + String(i + 1).padStart(2, "0") + '</span>';
      var h = function () { if (!moved) go(i); };
      b.addEventListener("click", h);
      cardOffs.push(function () { b.removeEventListener("click", h); });
      pivot.appendChild(b); cards.push(b);
    })(i);
    shown = -1; layout();
  }
  var moved = false;
  function layout() {
    pivot.style.top = (stage.clientHeight * 0.4 + opts.radius).toFixed(1) + "px";
    render();
  }
  function render() {
    var n = opts.count, cur = ((Math.round(pos) % n) + n) % n;
    for (var i = 0; i < n; i++) {
      var d = wrap(i - pos, n), ad = Math.abs(d);
      var a = d * opts.step;
      var s = 1 + (opts.scale - 1) * Math.max(0, 1 - ad);
      var op = ad > 3.4 ? 0 : Math.min(1, 1 - Math.max(0, ad - 2.2) / 1.2);
      var c = cards[i];
      c.style.transform = "translate(-50%,-50%) rotate(" + a.toFixed(2) + "deg) translateY(" + (-opts.radius) + "px) scale(" + s.toFixed(3) + ")";
      c.style.opacity = op.toFixed(3);
      c.style.zIndex = String(100 - Math.round(ad * 10));
      c.style.visibility = op <= 0 ? "hidden" : "visible";
      var on = i === cur;
      if (on !== c.classList.contains("is-active")) c.classList.toggle("is-active", on);
      if (on) c.setAttribute("aria-current", "true"); else c.removeAttribute("aria-current");
    }
    if (shown !== cur) {
      shown = cur;
      nameEl.innerHTML = "";
      nameEl.appendChild(document.createTextNode(NAMES[cur % NAMES.length]));
      var sp = document.createElement("span"); sp.textContent = String(cur + 1).padStart(2, "0") + " / " + String(n).padStart(2, "0");
      nameEl.appendChild(sp);
    }
  }
  function frame(t) {
    var dt = Math.min(0.05, (t - last) / 1000); last = t;
    var diff = target - pos;
    if (Math.abs(diff) < 0.0015) { pos = target; raf = 0; render(); return; }
    pos += diff * (1 - Math.exp(-dt * 9));
    render();
    raf = requestAnimationFrame(frame);
  }
  function kick() {
    if (reduce) { pos = target; render(); return; }
    if (!raf) { last = performance.now(); raf = requestAnimationFrame(frame); }
  }
  function by(d) { target = Math.round(target) + d; kick(); }
  function go(i) { target = Math.round(target) + wrap(i - Math.round(target), opts.count); kick(); }

  function pd(e) {
    if (e.target.closest && e.target.closest(".ef-rad__btn")) return;
    if (e.button !== undefined && e.button > 0) return;
    drag = { x: e.clientX, lx: e.clientX, lt: performance.now(), v: 0 };
    moved = false;
  }
  function pm(e) {
    if (!drag) return;
    if (!moved && Math.abs(e.clientX - drag.x) > 4) {
      moved = true; stage.classList.add("is-drag");
      try { stage.setPointerCapture(e.pointerId); } catch (x) {}
    }
    if (!moved) return;
    var dx = e.clientX - drag.lx, now = performance.now();
    var per = opts.radius * opts.step * Math.PI / 180;
    drag.v = (-dx / per) / Math.max(0.008, (now - drag.lt) / 1000);
    drag.lx = e.clientX; drag.lt = now;
    if (!reduce) { pos -= dx / per; target = pos; render(); }
  }
  function pu(e) {
    if (!drag) return;
    var total = e.clientX - drag.x, was = moved;
    var v = drag.v;
    drag = null; stage.classList.remove("is-drag");
    if (was) {
      if (reduce) target = Math.round(pos) + (Math.abs(total) > 24 ? (total < 0 ? 1 : -1) : 0);
      else target = Math.round(pos + Math.max(-2, Math.min(2, v * 0.12)));
      kick();
    }
    setTimeout(function () { moved = false; }, 0);
  }
  function wheel(e) {
    e.preventDefault();
    var d = Math.abs(e.deltaY) > Math.abs(e.deltaX) ? e.deltaY : e.deltaX;
    var now = performance.now();
    if (now - wheelAt > 250) wheelAcc = 0;
    wheelAt = now; wheelAcc += d;
    if (Math.abs(wheelAcc) >= 50) { by(wheelAcc > 0 ? 1 : -1); wheelAcc = 0; wheelAt = now + 150; }
  }
  function key(e) {
    var d = e.key === "ArrowRight" || e.key === "ArrowDown" ? 1 : e.key === "ArrowLeft" || e.key === "ArrowUp" ? -1 : 0;
    if (!d) return;
    e.preventDefault(); by(d);
  }
  function btn(e) {
    var b = e.target.closest ? e.target.closest(".ef-rad__btn") : null;
    if (b) by(parseInt(b.getAttribute("data-dir"), 10));
  }
  function on(el, ev, fn, op) { el.addEventListener(ev, fn, op); offs.push(function () { el.removeEventListener(ev, fn, op); }); }
  on(stage, "pointerdown", pd);
  on(stage, "pointermove", pm);
  on(stage, "pointerup", pu);
  on(stage, "pointercancel", pu);
  on(stage, "wheel", wheel, { passive: false });
  on(stage, "keydown", key);
  on(stage, "click", btn);
  if (window.ResizeObserver) { ro = new ResizeObserver(function () { layout(); }); ro.observe(stage); }

  function update(next) {
    var prev = opts;
    opts = Object.assign({ count: 8, radius: 330, step: 20, scale: 1.3, accent: "#e6ff55" }, opts, next || {});
    opts.count = Math.max(5, Math.min(12, Math.round(opts.count)));
    stage.style.setProperty("--ef-acc", opts.accent);
    if (prev.count !== opts.count || prev.accent !== opts.accent || !cards.length) build(); else layout();
  }
  update(o);
  return { update: update, go: go, destroy: function () { if (raf) cancelAnimationFrame(raf); raf = 0; offs.forEach(function (f) { f(); }); cardOffs.forEach(function (f) { f(); }); offs = []; cardOffs = []; if (ro) ro.disconnect(); } };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"accent":"#e6ff55","count":8,"radius":330,"step":20,"scale":1.3});

How it works

A pivot point sits below the stage. Every card is centred on it, rotated by its signed distance from the current position times the angle step, then moved up by the radius, so the cards ride an arc without any trigonometry. The position is one float: dragging, wheel notches, keys and buttons move a target and an exponential ease pulls the position toward it, so the dial can be turned part way and then snaps to the nearest card. Distance from the centre drives scale, opacity and z-index, and distances wrap so the dial loops forever.

Accessibility and performance

The stage is focusable and labelled; left and right arrows, the two buttons and clicking any card move the dial. The caption is a polite live region that announces the active card, and only the active card carries aria-current. With prefers-reduced-motion the dial jumps straight to the chosen card with no easing.

While moving, each frame writes a transform, opacity and z-index to at most a dozen cards; the loop stops as soon as the dial settles. Cards beyond about three positions away are hidden.

SectionGallery
TechniquesCarousel, Pointer interaction
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
Inspired byRadial Cards Slider (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. Image drag with tiltA row of generated art cards you drag with the pointer, tilting and skewing with speed, gliding on release and easing back from the ends. 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 arc be flatter or tighter?

Raise the radius for a flatter arc, and change the angle step to control how much neighbouring cards overlap.

Does wheel hijack page scrolling?

Only while the pointer is over the dial. Notches are grouped so one flick moves one card.

How do I use real content in the cards?

Replace the SVG and number in build() with your own markup. Cards are buttons, so clicking a side card brings it to the centre.