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">←</button>
<p class="ef-rad__name" aria-live="polite"></p>
<button type="button" class="ef-rad__btn" data-dir="1" aria-label="Next card">→</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.
| Section | Gallery |
|---|---|
| Techniques | Carousel, Pointer interaction |
| Stack | Vanilla JS, CSS |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| Inspired by | Radial 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.
More elements
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.