Copied!
Gallery

3D ring carousel

Cards on a CSS 3D ring that rotates one step at a time, with buttons, arrow keys and autoplay.

← Library

Element · Vanilla JS + CSS 3DUpdated November 5, 2026

3DElementVanilla JS + CSS 3D

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 "3D ring carousel" for my site using the Enrichr Library element at https://enrichr.dev/elements/ring-carousel.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/ring-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:
- count: 6
- radius: 220
- stepMs: 2200
- auto: 1
- hue: 150

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/ring-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-ring" role="group" aria-roledescription="carousel" aria-label="Rotating ring of cards" tabindex="0">
  <div class="ef-ring__stage"><ul class="ef-ring__ring"></ul></div>
  <div class="ef-ring__ctl">
    <button class="ef-ring__prev" type="button" aria-label="Previous card">&#8249;</button>
    <button class="ef-ring__next" type="button" aria-label="Next card">&#8250;</button>
  </div>
</div>

CSS

.ef-ring { position: relative; outline-offset: 4px; }
.ef-ring__stage { height: 17rem; perspective: 1200px; display: flex; align-items: center; justify-content: center; overflow: hidden; }
.ef-ring__ring { list-style: none; margin: 0; padding: 0; position: relative; width: 9rem; height: 12rem; transform-style: preserve-3d; }
.ef-ring__card { position: absolute; inset: 0; border-radius: 0.8rem; display: flex; align-items: flex-end; padding: 0.8rem; font-size: 1.6rem; font-weight: 800; color: #fff; backface-visibility: hidden; background: linear-gradient(160deg, hsl(var(--ef-h) 55% 38%), hsl(calc(var(--ef-h) + 40) 60% 18%)); box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3); }
.ef-ring__ctl { display: flex; justify-content: center; gap: 0.6rem; margin-top: 0.6rem; }
.ef-ring__ctl button { width: 2.6rem; height: 2.6rem; border-radius: 50%; border: 1px solid currentColor; background: transparent; color: inherit; font-size: 1.4rem; line-height: 1; cursor: pointer; }
.ef-ring__ctl button:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }

JavaScript

// Ring carousel: cards sit on a 3D ring that rotates one step at a time with an eased tween.
// mount(root, { count (3-12), radius (px), stepMs (autoplay interval), auto (1 or 0), hue (0-360) }) -> { update, next, prev, destroy }
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var ring = root.querySelector(".ef-ring__ring");
  var box = root.querySelector(".ef-ring");
  var opts = {}, idx = 0, angle = 0, target = 0, raf = 0, timer = 0, visible = true, io = null;

  function step() { return 360 / opts.count; }
  function build() {
    ring.textContent = "";
    for (var i = 0; i < opts.count; i++) {
      var li = document.createElement("li");
      li.className = "ef-ring__card";
      li.style.setProperty("--ef-h", String((opts.hue + i * 30) % 360));
      li.style.transform = "rotateY(" + i * step() + "deg) translateZ(" + opts.radius + "px)";
      li.setAttribute("aria-label", i + 1 + " of " + opts.count);
      li.textContent = String(i + 1).padStart(2, "0");
      ring.appendChild(li);
    }
    idx = 0; angle = 0; target = 0;
    ring.style.transform = "translateZ(-" + opts.radius + "px) rotateY(0deg)";
    schedule();
  }
  function draw() {
    ring.style.transform = "translateZ(-" + opts.radius + "px) rotateY(" + angle.toFixed(2) + "deg)";
  }
  function loop() {
    var d = target - angle;
    if (reduce || Math.abs(d) < 0.05) { angle = target; draw(); raf = 0; return; }
    angle += d * 0.12;
    draw();
    raf = requestAnimationFrame(loop);
  }
  function go(dir) { idx += dir; target = -idx * step(); if (!raf) raf = requestAnimationFrame(loop); }
  function schedule() {
    clearInterval(timer);
    if (opts.auto && !reduce) timer = setInterval(function () { if (visible) go(1); }, opts.stepMs);
  }
  function update(next) {
    var prevCount = opts.count, prevRadius = opts.radius, prevHue = opts.hue;
    opts = Object.assign({ count: 6, radius: 220, stepMs: 2200, auto: 1, hue: 150 }, opts, next || {});
    if (opts.count !== prevCount || opts.radius !== prevRadius || opts.hue !== prevHue) build(); else schedule();
  }
  function prevBtn() { go(-1); schedule(); }
  function nextBtn() { go(1); schedule(); }
  function keys(e) { if (e.key === "ArrowLeft") { prevBtn(); e.preventDefault(); } else if (e.key === "ArrowRight") { nextBtn(); e.preventDefault(); } }

  update(o);
  root.querySelector(".ef-ring__prev").addEventListener("click", prevBtn);
  root.querySelector(".ef-ring__next").addEventListener("click", nextBtn);
  box.addEventListener("keydown", keys);
  if ("IntersectionObserver" in window) { io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; }); io.observe(root); }
  return { update: update, next: nextBtn, prev: prevBtn, destroy: function () { clearInterval(timer); cancelAnimationFrame(raf); if (io) io.disconnect(); box.removeEventListener("keydown", keys); } };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"count":6,"radius":220,"stepMs":2200,"auto":1,"hue":150});

How it works

Each card is placed with rotateY and translateZ on a preserve-3d ring, and the whole ring is pushed back by the radius so the front card sits at the plane. Moving one step changes a target angle of 360 divided by the card count, and a short loop eases the real angle toward it, which gives the stepped, weighty feel.

Accessibility and performance

The wrapper is a labelled carousel region with previous and next buttons and arrow-key control. Autoplay is off for reduced motion, and the rotation jumps instead of tweening.

Pure CSS 3D transforms, one transform write per frame while moving, and no work when idle. Autoplay pauses off screen.

SectionGallery
TechniquesPointer interaction, CSS 3D, Carousel
StackVanilla JS, CSS 3D
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested

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.

Rule sweep listA line sweeps across a menu row on hover or focus and exits through the opposite side. Depth parallax cardLayered shapes shift by different amounts as the pointer moves, drifting on touch screens. Blend-mode cursorA custom cursor dot that inverts what is beneath it and grows over links, for fine pointers only.

FAQ

Why does it step instead of spin freely?

Stepping keeps one card centred and readable between moves. It also makes keyboard and button control predictable.

How many cards can it handle?

Up to about 12 look good at a normal radius. Raise the radius as you add cards so they do not overlap.

Can the cards hold images?

Yes. Put an img in each card element; the ring only cares about the card box.