Copied!
Gallery

Radial arc gallery

Tall photo cards ride round the top of a big wheel, the one at the top large and bright, turning one step every few seconds or on demand.

← Library

Element · Vanilla JS + CSSUpdated November 6, 2026

GalleryElementVanilla 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 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.

SectionGallery
TechniquesCarousel
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
On CodePenOpen the live pen on our CodePen account; fork it to experiment.
Inspired byModern 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.

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. WebGL image transitionThree generated slides that change with a WebGL shader transition: dissolve, wipe-warp or pixel.

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.