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 arc gallery" for my site using the Enrichr Library element at https://enrichr.dev/elements/radial-arc-gallery.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/radial-arc-gallery.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:
- interval: 4
- duration: 1.4
- radius: 35
- background: #0c1e18
- accent: #e6ff55
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-arc-gallery.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-rag" role="region" aria-roledescription="carousel" aria-label="Corgi photo wheel">
<div class="ef-rag__stage" tabindex="0" aria-label="Photo wheel. Use the left and right arrow keys to turn it.">
<figure class="ef-rag__card"><img class="ef-rag__img" src="/img/library/corgi-1.jpg" alt="Corgi puppy with huge ears on an orange backdrop" draggable="false"></figure>
<figure class="ef-rag__card"><img class="ef-rag__img" src="/img/library/corgi-2.jpg" alt="Smiling corgi in a field of purple and orange pansies" draggable="false"></figure>
<figure class="ef-rag__card"><img class="ef-rag__img" src="/img/library/corgi-3.jpg" alt="Corgi standing on a rock in front of autumn leaves" draggable="false"></figure>
<figure class="ef-rag__card"><img class="ef-rag__img" src="/img/library/corgi-4.jpg" alt="Corgi running down a dirt road at sunset" style="object-position: 18% 50%" draggable="false"></figure>
<figure class="ef-rag__card"><img class="ef-rag__img" src="/img/library/corgi-5.jpg" alt="Corgi resting its chin on the floor of a white studio" style="object-position: 68% 50%" draggable="false"></figure>
<figure class="ef-rag__card"><img class="ef-rag__img" src="/img/library/corgi-6.jpg" alt="Corgi with its tongue out in a white studio" style="object-position: 48% 50%" draggable="false"></figure>
</div>
<button type="button" class="ef-rag__btn ef-rag__prev" aria-label="Previous photo"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><polyline points="15 18 9 12 15 6"/></svg></button>
<button type="button" class="ef-rag__btn ef-rag__next" aria-label="Next photo"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><polyline points="9 18 15 12 9 6"/></svg></button>
<p class="ef-rag__live" aria-live="polite" aria-atomic="true"></p>
</div>CSS
/* Enrichr Library · https://enrichr.dev */
.ef-rag { --ef-rag-bg: #0c1e18; --ef-rag-ink: #fbf8f1; --ef-rag-acc: #e6ff55; position: relative; box-sizing: border-box; width: 100%; height: 100%; min-height: 30rem; flex: 1 1 auto; overflow: hidden; border-radius: 1rem; background: var(--ef-rag-bg); color: var(--ef-rag-ink); font-family: "Instrument Sans", system-ui, sans-serif; }
.ef-rag .ef-rag__stage { position: absolute; inset: 0; overflow: hidden; outline: none; border-radius: inherit; touch-action: pan-y; }
.ef-rag .ef-rag__stage:focus-visible { box-shadow: inset 0 0 0 3px var(--ef-rag-acc); }
.ef-rag .ef-rag__card { position: absolute; left: 0; top: 0; margin: 0; width: 192px; height: 341px; border-radius: 1.5rem; overflow: hidden; background: color-mix(in srgb, var(--ef-rag-ink) 7%, var(--ef-rag-bg)); cursor: pointer; box-shadow: 0 20px 40px -10px rgba(0, 0, 0, 0.6); transform-origin: center center; transition: opacity 0.3s ease; will-change: transform, opacity; }
.ef-rag .ef-rag__img { display: block; width: 100%; height: 100%; object-fit: cover; user-select: none; -webkit-user-drag: none; }
.ef-rag .ef-rag__btn { position: absolute; top: 50%; z-index: 200; display: grid; place-items: center; box-sizing: content-box; width: 24px; height: 24px; margin: 0; padding: 1rem; border: 0; border-radius: 50%; background: color-mix(in srgb, var(--ef-rag-ink) 10%, transparent); -webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px); color: var(--ef-rag-ink); cursor: pointer; transform: translateY(-50%); transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1); }
.ef-rag .ef-rag__prev { left: 2rem; }
.ef-rag .ef-rag__next { right: 2rem; }
.ef-rag .ef-rag__btn:hover { background: color-mix(in srgb, var(--ef-rag-ink) 20%, transparent); }
.ef-rag .ef-rag__btn:focus-visible { outline: 3px solid var(--ef-rag-acc); outline-offset: 3px; }
.ef-rag .ef-rag__btn svg { width: 24px; height: 24px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.ef-rag .ef-rag__live { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
.ef-rag.is-narrow .ef-rag__prev { left: 0.75rem; }
.ef-rag.is-narrow .ef-rag__next { right: 0.75rem; }
.ef-rag.is-narrow .ef-rag__btn { padding: 0.75rem; }JavaScript
// Enrichr Library · https://enrichr.dev
// Radial arc gallery: tall 9:16 photo cards sit on a big wheel whose centre is near the bottom of the box, so only the top
// of the wheel shows. One angle drives everything: each card's place on the circle is its slot angle plus that offset, and
// its distance from the top (measured the same way as the original pen) sets its size (1.4 at the top, down to 0.3), its
// fade and its stacking. Turning tweens the offset by one slot on a power2 in-out curve (1.4 s), every 4 s on autoplay;
// a click on a card turns it to the top in 1 s. Cards that fade to nothing are hidden. Prev and Next buttons, the arrow keys,
// Home and End also turn it; the photo now on top is announced. Autoplay pauses on hover, on focus, off screen and in hidden
// tabs, and stops for good once you steer it yourself. Reduced motion: no autoplay, and each turn jumps without a tween.
// mount(root, { interval (autoplay seconds, 0 is off), duration (seconds per turn), radius (% of the box height),
// background, accent })
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var box = root.querySelector(".ef-rag");
var stage = root.querySelector(".ef-rag__stage");
var cards = [].slice.call(root.querySelectorAll(".ef-rag__card"));
var prev = root.querySelector(".ef-rag__prev"), next = root.querySelector(".ef-rag__next");
var live = root.querySelector(".ef-rag__live");
var n = Math.max(1, cards.length), TAU = Math.PI * 2, STEP = TAU / n, TOP = -Math.PI / 2;
var opts = {}, angle = TOP, target = TOP, tw = null, raf = 0, last = 0, auto = 0;
var stopped = false, hover = false, focused = false, visible = true, destroyed = false;
var W = 1, H = 1, cw = 192, ch = 341, R = 252, cy = 0;
var io = null, ro = null, offs = [];
function num(v, d) { v = Number(v); return isFinite(v) ? v : d; }
// GSAP power2.inOut (a cubic curve)
function ease(p) { return p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2; }
function place() {
for (var i = 0; i < cards.length; i++) {
var a = i * STEP + angle;
var x = Math.cos(a) * R, y = Math.sin(a) * R;
// the pen's own angle maths: wrap to (-PI, PI], then distance from the top
var nm = a % TAU;
if (nm > Math.PI) nm -= TAU;
if (nm < -Math.PI) nm += TAU;
var dist = Math.abs(nm - TOP);
var s = Math.max(0.3, 1.4 - dist / (Math.PI / 1.5));
var op = Math.max(0, 1 - dist / (Math.PI / 1.2));
var st = cards[i].style;
st.transform = "translate3d(" + (W / 2 + x - cw / 2).toFixed(2) + "px," + (cy + y - ch / 2).toFixed(2) + "px,0) scale(" + s.toFixed(4) + ")";
st.opacity = op.toFixed(4);
st.zIndex = String(Math.floor(100 - dist * 20));
st.display = op > 0 ? "block" : "none";
}
}
function current() { return ((Math.round((TOP - target) / STEP) % n) + n) % n; }
function mark(say) {
var c = current();
cards.forEach(function (card, i) {
card.setAttribute("role", "group");
card.setAttribute("aria-roledescription", "slide");
card.setAttribute("aria-label", (i + 1) + " of " + n);
if (i === c) card.removeAttribute("aria-hidden"); else card.setAttribute("aria-hidden", "true");
});
if (say) {
var img = cards[c] && cards[c].querySelector("img");
live.textContent = "Photo " + (c + 1) + " of " + n + (img && img.alt ? ": " + img.alt : "");
}
}
function go(to, dur, say) {
target = to;
if (reduce || dur <= 0) { tw = null; angle = to; place(); }
else tw = { from: angle, to: to, t: 0, d: dur };
mark(say);
}
// dir 1 turns the wheel clockwise (the card on the left comes up), -1 anticlockwise (the card on the right comes up)
function turn(dir, user) {
if (user) { stopped = true; auto = 0; }
go(target + STEP * dir, opts.duration, user);
}
function toCard(i, user) {
if (user) { stopped = true; auto = 0; }
var base = TOP - i * STEP, k = Math.round((target - base) / TAU); // nearest way round
go(base + k * TAU, 1, user);
}
function loop(now) {
raf = requestAnimationFrame(loop);
var dt = Math.min(0.1, (now - last) / 1000 || 0); last = now;
if (!visible || document.hidden) return;
if (tw) {
tw.t += dt;
var p = Math.min(1, tw.t / tw.d);
angle = tw.from + (tw.to - tw.from) * ease(p);
place();
if (p >= 1) tw = null;
}
if (!reduce && opts.interval > 0 && !stopped && !hover && !focused) {
auto += dt;
if (auto >= opts.interval) { auto = 0; turn(1, false); }
}
}
function size() {
W = Math.max(1, box.clientWidth); H = Math.max(1, box.clientHeight);
// card: clamp(140px, 15vw, 220px) of the box width, kept short enough for the box height; 9:16
cw = Math.min(Math.min(220, Math.max(140, W * 0.15)), H * 0.27); ch = cw * 16 / 9;
R = Math.min(400, H * opts.radius / 100);
// the circle centre: same place as in the pen (it sits low, so the top card is lifted by 0.8 of the radius)
cy = H + R * 0.2 - cw * 1.77 / 4;
cards.forEach(function (c) { c.style.width = cw.toFixed(2) + "px"; c.style.height = ch.toFixed(2) + "px"; });
box.classList.toggle("is-narrow", W < 560);
place();
}
function onKey(e) {
var k = e.key;
if (k === "ArrowRight") { e.preventDefault(); turn(-1, true); }
else if (k === "ArrowLeft") { e.preventDefault(); turn(1, true); }
else if (k === "Home") { e.preventDefault(); toCard(0, true); }
else if (k === "End") { e.preventDefault(); toCard(n - 1, true); }
}
function onNext() { turn(-1, true); }
function onPrev() { turn(1, true); }
function onEnter(e) { if (e.pointerType === "mouse") hover = true; }
function onLeave() { hover = false; }
function onFocusIn() { focused = true; }
function onFocusOut(e) { if (!box.contains(e.relatedTarget)) focused = false; }
cards.forEach(function (card, i) {
function onClick() { toCard(i, true); }
card.addEventListener("click", onClick);
offs.push(function () { card.removeEventListener("click", onClick); });
});
box.addEventListener("keydown", onKey);
next.addEventListener("click", onNext);
prev.addEventListener("click", onPrev);
box.addEventListener("pointerenter", onEnter);
box.addEventListener("pointerleave", onLeave);
box.addEventListener("focusin", onFocusIn);
box.addEventListener("focusout", onFocusOut);
offs.push(function () {
box.removeEventListener("keydown", onKey); next.removeEventListener("click", onNext); prev.removeEventListener("click", onPrev);
box.removeEventListener("pointerenter", onEnter); box.removeEventListener("pointerleave", onLeave);
box.removeEventListener("focusin", onFocusIn); box.removeEventListener("focusout", onFocusOut);
});
function update(partial) {
opts = Object.assign({ interval: 4, duration: 1.4, radius: 35, background: "#0c1e18", accent: "#e6ff55" }, opts, partial || {});
opts.interval = Math.max(0, num(opts.interval, 4));
opts.duration = Math.max(0.1, num(opts.duration, 1.4));
opts.radius = Math.min(60, Math.max(10, num(opts.radius, 35)));
box.style.setProperty("--ef-rag-bg", opts.background);
box.style.setProperty("--ef-rag-acc", opts.accent);
if (destroyed) return;
size();
}
update(o);
mark(false);
if ("IntersectionObserver" in window) { io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; }); io.observe(box); }
if ("ResizeObserver" in window) { ro = new ResizeObserver(function () { if (!destroyed) size(); }); ro.observe(box); }
last = performance.now(); raf = requestAnimationFrame(loop);
return {
update: update,
destroy: function () {
destroyed = true; cancelAnimationFrame(raf);
offs.forEach(function (f) { f(); }); offs = [];
if (io) io.disconnect();
if (ro) ro.disconnect();
}
};
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"interval":4,"duration":1.4,"radius":35,"background":"#0c1e18","accent":"#e6ff55"});How it works
Six 9:16 cards sit on a circle whose centre is below the middle of the box, so only the top of the wheel shows. One angle drives everything: each card's place is its slot angle plus that offset. The card's distance from the top sets its size (1.4 at the top, down to 0.3), its fade and its stacking order, and cards that fade to nothing are hidden. A turn tweens the offset by one slot over 1.4 seconds on a power2 in-out curve, the same curve GSAP uses, with plain requestAnimationFrame; the fade also eases through a short CSS transition. Autoplay turns it every 4 seconds. Clicking a card turns it to the top the short way round in 1 second. Card size follows the width of the box and the wheel follows its height.
Accessibility and performance
The gallery is a labelled carousel region. Previous and Next are real buttons with labels. The wheel itself takes focus, and Left, Right, Home and End turn it. The photo that reaches the top is announced in a polite live region, and the other cards are hidden from assistive technology. Autoplay pauses on hover and on focus and stops for good once you steer it. With prefers-reduced-motion there is no autoplay and each turn jumps straight to its place.
A frame only writes a transform, an opacity and a z-index on six cards, so the browser composites the move without layout. The loop does nothing at rest except count autoplay time, and it pauses off screen and in hidden tabs. A ResizeObserver resizes the cards and the wheel when the box changes.
| Section | Gallery |
|---|---|
| Techniques | Carousel |
| Stack | Vanilla JS, CSS |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| On CodePen | Open the live pen on our CodePen account; fork it to experiment. |
| Inspired by | Modern Radial 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.
More elements
FAQ
How do I use my own photos?
Change the img src and alt in each figure in the markup. Any number of cards works; the angle between them is a full turn divided by the count. Tall photos suit the 9:16 cards best.
Can I stop the autoplay?
Set Autoplay to 0. It also stops by itself the first time someone uses the arrows, the keys or a card, and it waits while the pointer or focus is on the gallery.
Why do cards on the lower left fade in suddenly?
That is the original's own angle maths: distance from the top is measured after wrapping the angle to the range minus pi to pi, so the lower left counts as far away and the upper left as near. It was kept so the motion matches the pen.
Who took the photos?
Photos: Alvan Nee and fatty corgi on Unsplash, free to use under the Unsplash License.