Copied!
Gallery

Gather arc carousel

Photo cards swing on a wide arc from one far pivot, the lit centre card lifted over the rest, under a title, a line of text and two round arrows.

← 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 "Gather arc carousel" for my site using the Enrichr Library element at https://enrichr.dev/elements/gather-arc-carousel.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/gather-arc-carousel.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:
- title: Gather here
- text: Seven corgis on one arc. Use the arrows, the arrow keys or the mouse wheel to bring the next one forward.
- angle: 22
- radius: 750
- duration: 800
- wheel: on
- centre: #0c1e18
- glow: #1a7a4c
- edge: #050d0a
- 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/gather-arc-carousel.json.

Code

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

HTML

<div class="ef-gac" role="region" aria-roledescription="carousel" aria-label="Corgi arc carousel">
  <div class="ef-gac__stage" tabindex="0" aria-label="Photo arc. Use the left and right arrow keys to move along it.">
    <div class="ef-gac__track">
      <figure class="ef-gac__card"><img class="ef-gac__img" src="/img/library/corgi-2.jpg" alt="Smiling corgi in a field of purple and orange pansies" draggable="false"></figure>
      <figure class="ef-gac__card"><img class="ef-gac__img" src="/img/library/corgi-4.jpg" alt="Corgi running down a dirt road at sunset" style="object-position: 10% 50%" draggable="false"></figure>
      <figure class="ef-gac__card"><img class="ef-gac__img" src="/img/library/corgi-3.jpg" alt="Corgi standing on a rock in front of autumn leaves" draggable="false"></figure>
      <figure class="ef-gac__card"><img class="ef-gac__img" src="/img/library/corgi-1.jpg" alt="Corgi puppy with huge ears on an orange backdrop" draggable="false"></figure>
      <figure class="ef-gac__card"><img class="ef-gac__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>
      <figure class="ef-gac__card"><img class="ef-gac__img" src="/img/library/corgi-5.jpg" alt="Corgi resting its chin on the floor of a white studio" style="object-position: 70% 50%" draggable="false"></figure>
      <figure class="ef-gac__card"><img class="ef-gac__img" src="/img/library/corgi-2.jpg" alt="Close up of a happy corgi among the flowers" style="object-position: 50% 32%; transform: scale(1.35)" draggable="false"></figure>
    </div>
    <div class="ef-gac__content">
      <h2 class="ef-gac__title">Gather here</h2>
      <p class="ef-gac__text">Seven corgis on one arc. Use the arrows, the arrow keys or the mouse wheel to bring the next one forward.</p>
      <div class="ef-gac__nav">
        <button type="button" class="ef-gac__btn ef-gac__prev" aria-label="Previous photo"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><polyline points="14.5 6 8.5 12 14.5 18"/></svg></button>
        <button type="button" class="ef-gac__btn ef-gac__next" aria-label="Next photo"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><polyline points="9.5 6 15.5 12 9.5 18"/></svg></button>
      </div>
    </div>
  </div>
  <p class="ef-gac__live" aria-live="polite" aria-atomic="true"></p>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-gac { --ef-gac-c1: #0c1e18; --ef-gac-c2: #1a7a4c; --ef-gac-c3: #050d0a; --ef-gac-ink: #fbf8f1; --ef-gac-acc: #e6ff55; --ef-gac-k: 1; --ef-gac-cw: 200px; --ef-gac-ch: 300px; --ef-gac-r: 750px; --ef-gac-d: 800ms; position: relative; box-sizing: border-box; width: 100%; height: 100%; min-height: 30rem; flex: 1 1 auto; overflow: hidden; border-radius: 1rem; background: radial-gradient(circle at center, var(--ef-gac-c1) 0%, var(--ef-gac-c2) 60%, var(--ef-gac-c3) 100%); color: var(--ef-gac-ink); font-family: "Instrument Sans", system-ui, sans-serif; }
.ef-gac::after { content: ""; position: absolute; inset: 0; z-index: 40; border-radius: inherit; pointer-events: none; }
.ef-gac:has(.ef-gac__stage:focus-visible)::after { box-shadow: inset 0 0 0 3px var(--ef-gac-acc); }
.ef-gac .ef-gac__stage { position: absolute; left: 0; right: 0; top: calc(50px * var(--ef-gac-k)); height: 100%; display: flex; flex-direction: column; justify-content: center; align-items: center; perspective: 2000px; outline: none; touch-action: pan-y; }
.ef-gac .ef-gac__track { position: absolute; top: 15%; left: 50%; width: var(--ef-gac-cw); height: var(--ef-gac-ch); margin-left: calc(var(--ef-gac-cw) / -2); transform-style: preserve-3d; }
.ef-gac .ef-gac__card { position: absolute; inset: 0; margin: 0; overflow: hidden; border-radius: calc(30px * var(--ef-gac-k)); background: var(--ef-gac-c1); transform-origin: 50% var(--ef-gac-r); transition: transform var(--ef-gac-d) cubic-bezier(0.2, 0.8, 0.2, 1), filter var(--ef-gac-d), scale var(--ef-gac-d); filter: brightness(0.5); scale: 0.8; }
.ef-gac .ef-gac__card.is-active { filter: brightness(1); scale: 1.1; z-index: 10; }
.ef-gac .ef-gac__img { display: block; width: 100%; height: 100%; object-fit: cover; user-select: none; -webkit-user-drag: none; }
.ef-gac .ef-gac__content { position: relative; z-index: 20; box-sizing: border-box; max-width: 500px; padding: 0 1rem; display: flex; flex-direction: column; gap: 10px; text-align: center; text-shadow: 0 1px 14px rgba(0, 0, 0, 0.45); }
.ef-gac .ef-gac__title { margin: 0; font: 700 calc(2rem * max(var(--ef-gac-k), 0.78))/1.5 "Parkinsans", "Space Grotesk", sans-serif; letter-spacing: -0.01em; color: var(--ef-gac-ink); opacity: 1; }
.ef-gac .ef-gac__text { margin: 0; font-size: calc(1rem * max(var(--ef-gac-k), 0.88)); line-height: 1.6; color: var(--ef-gac-ink); opacity: 1; }
.ef-gac .ef-gac__nav { display: flex; justify-content: center; gap: 20px; margin: 20px 0 30px; }
.ef-gac .ef-gac__btn { display: grid; place-items: center; width: 50px; height: 50px; margin: 0; padding: 0; border: 1px solid var(--ef-gac-ink); border-radius: 50%; background: transparent; color: var(--ef-gac-ink); cursor: pointer; transition: background-color 0.2s ease, opacity 0.2s ease; }
.ef-gac .ef-gac__btn:hover { background: color-mix(in srgb, var(--ef-gac-ink) 14%, transparent); }
.ef-gac .ef-gac__btn:focus-visible { outline: 3px solid var(--ef-gac-acc); outline-offset: 3px; }
.ef-gac .ef-gac__btn[aria-disabled="true"] { opacity: 0.4; cursor: default; }
.ef-gac .ef-gac__btn svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.ef-gac .ef-gac__live { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }

JavaScript

// Enrichr Library · https://enrichr.dev
// Gather arc carousel: photo cards hang from one far-away pivot, 750 px below the top of the cards, so turning each card by
// 22 degrees per step from the current one spreads them on a wide arc that falls away to both sides. The current card is
// lit and scaled up to 1.1 around that same pivot, which lifts it above the rest; the others are dimmed to half brightness
// and scaled to 0.8. Moving is a CSS transition: 0.8 s on a cubic-bezier(0.2, 0.8, 0.2, 1) curve for the turn, and the
// default ease for the light and the size. A title, a line of text and two round arrow buttons sit over the bottom of the
// lit card. The arc has two ends (no loop). The arrow buttons, the left and right arrow keys, Home and End, the mouse wheel
// (one step per 600 ms, and the page scrolls on once you reach an end) and a sideways swipe all move it; the new photo is
// announced. Everything scales down with the box. Reduced motion: cards jump to their new places without a transition.
// mount(root, { title, text, angle (deg between cards), radius (px from cards to the pivot), duration (ms), wheel ("on"|"off"),
//               centre, glow, edge (the three colours of the radial background), accent })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var box = root.querySelector(".ef-gac");
  var stage = root.querySelector(".ef-gac__stage");
  var cards = [].slice.call(root.querySelectorAll(".ef-gac__card"));
  var title = root.querySelector(".ef-gac__title"), text = root.querySelector(".ef-gac__text");
  var prev = root.querySelector(".ef-gac__prev"), next = root.querySelector(".ef-gac__next");
  var live = root.querySelector(".ef-gac__live");
  var n = Math.max(1, cards.length), cur = Math.floor(cards.length / 2);
  var opts = {}, k = 1, lastWheel = 0, drag = null, ro = null, offs = [], destroyed = false;

  function num(v, d) { v = Number(v); return isFinite(v) ? v : d; }
  function layout() {
    cards.forEach(function (card, i) {
      card.style.transform = "rotate(" + ((i - cur) * opts.angle) + "deg)";
      card.classList.toggle("is-active", i === cur);
      card.setAttribute("role", "group");
      card.setAttribute("aria-roledescription", "slide");
      card.setAttribute("aria-label", (i + 1) + " of " + n);
      if (i === cur) card.removeAttribute("aria-hidden"); else card.setAttribute("aria-hidden", "true");
    });
    prev.setAttribute("aria-disabled", cur <= 0 ? "true" : "false");
    next.setAttribute("aria-disabled", cur >= n - 1 ? "true" : "false");
  }
  function go(i) {
    if (i < 0 || i >= n || i === cur) return false;
    cur = i;
    layout();
    var img = cards[cur].querySelector("img");
    live.textContent = "Photo " + (cur + 1) + " of " + n + (img && img.alt ? ": " + img.alt : "");
    return true;
  }
  function move(dir) { return go(cur + dir); }

  function size() {
    var W = Math.max(1, box.clientWidth), H = Math.max(1, box.clientHeight);
    // the pen is laid out for a 720 px tall window; smaller boxes get the same picture, scaled down
    k = Math.max(0.5, Math.min(1, H / 720, W / 700));
    box.style.setProperty("--ef-gac-k", k.toFixed(4));
    box.style.setProperty("--ef-gac-cw", (200 * k).toFixed(2) + "px");
    box.style.setProperty("--ef-gac-ch", (300 * k).toFixed(2) + "px");
    box.style.setProperty("--ef-gac-r", (opts.radius * k).toFixed(2) + "px");
  }

  function onKey(e) {
    var key = e.key;
    if (key === "ArrowRight") { e.preventDefault(); move(1); }
    else if (key === "ArrowLeft") { e.preventDefault(); move(-1); }
    else if (key === "Home") { e.preventDefault(); go(0); }
    else if (key === "End") { e.preventDefault(); go(n - 1); }
  }
  function onWheel(e) {
    if (opts.wheel !== "on") return;
    var d = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
    if (!d) return;
    var dir = d > 0 ? 1 : -1;
    if (cur + dir < 0 || cur + dir >= n) return; // at an end: let the page scroll on
    e.preventDefault();
    if (Date.now() - lastWheel < 600) return;
    lastWheel = Date.now();
    move(dir);
  }
  function down(e) { if (e.button !== undefined && e.button !== 0) return; if (e.target.closest && e.target.closest("button")) return; drag = { x: e.clientX, id: e.pointerId }; }
  function up(e) {
    if (!drag || e.pointerId !== drag.id) return;
    var dx = e.clientX - drag.x; drag = null;
    if (Math.abs(dx) > 40) move(dx < 0 ? 1 : -1);
  }
  function cancel() { drag = null; }
  function onPrev() { move(-1); }
  function onNext() { move(1); }
  box.addEventListener("keydown", onKey);
  box.addEventListener("wheel", onWheel, { passive: false });
  stage.addEventListener("pointerdown", down);
  stage.addEventListener("pointerup", up);
  stage.addEventListener("pointercancel", cancel);
  prev.addEventListener("click", onPrev);
  next.addEventListener("click", onNext);
  offs.push(function () {
    box.removeEventListener("keydown", onKey); box.removeEventListener("wheel", onWheel, { passive: false });
    stage.removeEventListener("pointerdown", down); stage.removeEventListener("pointerup", up); stage.removeEventListener("pointercancel", cancel);
    prev.removeEventListener("click", onPrev); next.removeEventListener("click", onNext);
  });

  function update(partial) {
    opts = Object.assign({ title: "Gather here", text: "Seven corgis on one arc. Use the arrows, the arrow keys or the mouse wheel to bring the next one forward.", angle: 22, radius: 750, duration: 800, wheel: "on", centre: "#0c1e18", glow: "#1a7a4c", edge: "#050d0a", accent: "#e6ff55" }, opts, partial || {});
    opts.angle = num(opts.angle, 22); opts.radius = Math.max(100, num(opts.radius, 750)); opts.duration = Math.max(0, num(opts.duration, 800));
    title.textContent = String(opts.title);
    text.textContent = String(opts.text);
    title.hidden = !String(opts.title).trim();
    text.hidden = !String(opts.text).trim();
    box.style.setProperty("--ef-gac-c1", opts.centre);
    box.style.setProperty("--ef-gac-c2", opts.glow);
    box.style.setProperty("--ef-gac-c3", opts.edge);
    box.style.setProperty("--ef-gac-acc", opts.accent);
    box.style.setProperty("--ef-gac-d", (reduce ? 0 : opts.duration) + "ms");
    if (destroyed) return;
    layout(); // before size(): on mount the cards take their places before the first style pass, so nothing sweeps in
    size();
  }

  update(o);
  if ("ResizeObserver" in window) { ro = new ResizeObserver(function () { if (!destroyed) size(); }); ro.observe(box); }
  return {
    update: update,
    destroy: function () {
      destroyed = true;
      offs.forEach(function (f) { f(); }); offs = [];
      if (ro) ro.disconnect();
    }
  };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"title":"Gather here","text":"Seven corgis on one arc. Use the arrows, the arrow keys or the mouse wheel to bring the next one forward.","angle":22,"radius":750,"duration":800,"wheel":"on","centre":"#0c1e18","glow":"#1a7a4c","edge":"#050d0a","accent":"#e6ff55"});

How it works

All the cards share one transform origin 750 px below their top edge, so rotating each one by 22 degrees per step away from the current card spreads them on a wide arc that falls away to both sides. The current card is scaled to 1.1 around that same far pivot, which lifts it well above the others, and lit to full brightness; the rest are scaled to 0.8 and dimmed to half. Moving is a plain CSS transition: 0.8 seconds on cubic-bezier(0.2, 0.8, 0.2, 1) for the turn and the default ease for the light and size. Unlike a looping deck, the arc has two ends. The layout is drawn for a 720 px tall box and scales down for smaller ones. The background is a radial gradient from a dark centre through a green ring to a darker edge.

Accessibility and performance

The carousel is a labelled region with a focusable photo arc: Left, Right, Home and End move it. Previous and Next are real buttons with labels, marked disabled at the ends. The new photo is announced in a polite live region, and the other cards are hidden from assistive technology. The title and text are real text with a soft shadow for contrast over the photo. With prefers-reduced-motion the cards jump to their new places without a transition.

Only transform, scale and filter change, through CSS transitions, so nothing runs in JavaScript between moves. The mouse wheel is throttled to one step every 600 ms and lets the page scroll on once the arc reaches an end. A ResizeObserver rescales the layout 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 byGather Carousel 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 is this different from a fanned card deck?

Here every card turns around a pivot far below it, so the cards trace a real circle and the lit card rises on the same arc. A fanned deck slides cards sideways and tilts them. This one also has two ends instead of looping.

Can the mouse wheel trap page scrolling?

Only while the arc can still move in that direction. At either end the wheel scrolls the page as usual, and you can switch the wheel off with the Mouse wheel setting.

How do I change the photos?

Edit the img src and alt in each figure in the markup. The middle card is shown first. A different count works too; widen or narrow the angle to fit.

Who took the photos?

Photos: Alvan Nee and fatty corgi on Unsplash, free to use under the Unsplash License.