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 "Tilted ring carousel" for my site using the Enrichr Library element at https://enrichr.dev/elements/tilted-ring-carousel.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/tilted-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:
- turn: 60
- side: 85
- duration: 0.8
- bounce: 0.2
- background: #fbf8f1
- ink: #0c1e18
- accent: #005236
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/tilted-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-trc" role="region" aria-roledescription="carousel" aria-label="Tilted corgi carousel">
<div class="ef-trc__stage" tabindex="0" aria-label="Photo row. Use the left and right arrow keys to move along it.">
<div class="ef-trc__row">
<div class="ef-trc__slot"><div class="ef-trc__slide"><div class="ef-trc__frame"><img class="ef-trc__img" src="/img/library/corgi-1.jpg" alt="Corgi puppy with huge ears on an orange backdrop" draggable="false"></div><p class="ef-trc__cap">orange backdrop</p></div></div>
<div class="ef-trc__slot"><div class="ef-trc__slide"><div class="ef-trc__frame"><img class="ef-trc__img" src="/img/library/corgi-2.jpg" alt="Smiling corgi in a field of purple and orange pansies" draggable="false"></div><p class="ef-trc__cap">flower field</p></div></div>
<div class="ef-trc__slot"><div class="ef-trc__slide"><div class="ef-trc__frame"><img class="ef-trc__img" src="/img/library/corgi-3.jpg" alt="Corgi standing on a rock in front of autumn leaves" draggable="false"></div><p class="ef-trc__cap">autumn rock</p></div></div>
<div class="ef-trc__slot"><div class="ef-trc__slide"><div class="ef-trc__frame"><img class="ef-trc__img" src="/img/library/corgi-4.jpg" alt="Corgi running down a dirt road at sunset" style="object-position: 0% 50%" draggable="false"></div><p class="ef-trc__cap">dirt road run</p></div></div>
<div class="ef-trc__slot"><div class="ef-trc__slide"><div class="ef-trc__frame"><img class="ef-trc__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"></div><p class="ef-trc__cap">nap time</p></div></div>
<div class="ef-trc__slot"><div class="ef-trc__slide"><div class="ef-trc__frame"><img class="ef-trc__img" src="/img/library/corgi-6.jpg" alt="Corgi with its tongue out in a white studio" style="object-position: 48% 50%" draggable="false"></div><p class="ef-trc__cap">studio stroll</p></div></div>
<div class="ef-trc__slot"><div class="ef-trc__slide"><div class="ef-trc__frame"><img class="ef-trc__img" src="/img/library/corgi-1.jpg" alt="Close up of the corgi puppy's big ears" style="transform: scale(1.6); transform-origin: 50% 33%" draggable="false"></div><p class="ef-trc__cap">all ears</p></div></div>
<div class="ef-trc__slot"><div class="ef-trc__slide"><div class="ef-trc__frame"><img class="ef-trc__img" src="/img/library/corgi-2.jpg" alt="Corgi paws among purple pansies" style="transform: scale(1.5); transform-origin: 50% 92%" draggable="false"></div><p class="ef-trc__cap">pansy patch</p></div></div>
<div class="ef-trc__slot"><div class="ef-trc__slide"><div class="ef-trc__frame"><img class="ef-trc__img" src="/img/library/corgi-3.jpg" alt="Close up of a smiling corgi in autumn sun" style="transform: scale(1.6); transform-origin: 50% 46%" draggable="false"></div><p class="ef-trc__cap">big smile</p></div></div>
<div class="ef-trc__slot"><div class="ef-trc__slide"><div class="ef-trc__frame"><img class="ef-trc__img" src="/img/library/corgi-6.jpg" alt="Corgi walking past a tiny stone statue" style="object-position: 30% 50%" draggable="false"></div><p class="ef-trc__cap">tiny statue</p></div></div>
</div>
</div>
<div class="ef-trc__ctl">
<button type="button" class="ef-trc__btn ef-trc__prev" aria-label="Previous photo"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m15 18-6-6 6-6"/></svg></button>
<div class="ef-trc__dots"></div>
<button type="button" class="ef-trc__btn ef-trc__next" aria-label="Next photo"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m9 18 6-6-6-6"/></svg></button>
</div>
<p class="ef-trc__live" aria-live="polite" aria-atomic="true"></p>
</div>CSS
/* Enrichr Library · https://enrichr.dev */
.ef-trc { --ef-trc-bg: #fbf8f1; --ef-trc-ink: #0c1e18; --ef-trc-acc: #005236; --ef-trc-w: 200px; position: relative; box-sizing: border-box; width: 100%; height: 100%; min-height: 28rem; flex: 1 1 auto; overflow: hidden; border-radius: 1rem; background: var(--ef-trc-bg); color: color-mix(in srgb, var(--ef-trc-ink) 92%, var(--ef-trc-bg)); font-family: "Parkinsans", "Space Grotesk", sans-serif; user-select: none; }
.ef-trc .ef-trc__stage { position: absolute; inset: 0; overflow: hidden; outline: none; border-radius: inherit; touch-action: pan-y; }
.ef-trc .ef-trc__stage:focus-visible { box-shadow: inset 0 0 0 3px var(--ef-trc-acc); }
.ef-trc .ef-trc__row { position: absolute; left: 0; top: 0; display: flex; width: max-content; will-change: transform; }
.ef-trc .ef-trc__slot { flex: none; width: var(--ef-trc-w); perspective: 800px; }
.ef-trc .ef-trc__slide { display: flex; flex-direction: column; align-items: center; gap: 0.5rem; width: var(--ef-trc-w); height: calc(var(--ef-trc-w) * 4 / 3); will-change: transform; }
.ef-trc .ef-trc__frame { flex: none; width: 100%; height: 100%; overflow: hidden; border-radius: 0.5rem; cursor: pointer; }
.ef-trc .ef-trc__img { display: block; width: 100%; height: 100%; object-fit: cover; -webkit-user-drag: none; }
.ef-trc .ef-trc__cap { flex: none; margin: 0; font-size: 0.75rem; line-height: 1rem; white-space: nowrap; color: var(--ef-trc-ink); opacity: 0; filter: blur(2px); transition: opacity 0.3s cubic-bezier(0.25, 0.1, 0.35, 1), filter 0.3s cubic-bezier(0.25, 0.1, 0.35, 1); will-change: opacity, filter; }
.ef-trc.is-md .ef-trc__cap { font-size: 0.875rem; line-height: 1.25rem; }
.ef-trc .ef-trc__slot.is-active .ef-trc__cap { opacity: 1; filter: blur(0); }
.ef-trc .ef-trc__ctl { position: absolute; left: 50%; bottom: 1rem; z-index: 5; display: flex; align-items: center; justify-content: center; gap: 1rem; padding: 0 0.5rem; border: 1px solid color-mix(in srgb, var(--ef-trc-ink) 7%, transparent); border-radius: 9999px; background: color-mix(in srgb, var(--ef-trc-ink) 3%, transparent); -webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px); box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1); color: color-mix(in srgb, var(--ef-trc-ink) 85%, var(--ef-trc-bg)); transform: translateX(-50%); }
.ef-trc .ef-trc__btn { display: grid; place-items: center; margin: 0; padding: 0.5rem; border: 0; border-radius: 50%; background: none; color: inherit; cursor: pointer; }
.ef-trc .ef-trc__btn svg { width: 24px; height: 24px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.ef-trc .ef-trc__btn[aria-disabled="true"] { opacity: 0.35; cursor: default; }
.ef-trc .ef-trc__dots { display: flex; align-items: center; justify-content: center; gap: 0.5rem; width: 180px; }
.ef-trc .ef-trc__dot { position: relative; flex: none; width: 0.5rem; height: 0.5rem; margin: 0; padding: 0; border: 0; border-radius: 9999px; background: color-mix(in srgb, currentColor 30%, transparent); cursor: pointer; transition: width 300ms cubic-bezier(0.4, 0, 0.2, 1), background-color 300ms cubic-bezier(0.4, 0, 0.2, 1); }
.ef-trc .ef-trc__dot::before { content: ""; position: absolute; inset: -0.6rem -0.25rem; }
.ef-trc .ef-trc__dot[aria-current="true"] { width: 1.75rem; background: var(--ef-trc-acc); }
.ef-trc .ef-trc__btn:focus-visible, .ef-trc .ef-trc__dot:focus-visible { outline: 2px solid var(--ef-trc-acc); outline-offset: 3px; }
.ef-trc .ef-trc__live { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
@media (prefers-reduced-motion: reduce) { .ef-trc .ef-trc__cap, .ef-trc .ef-trc__dot { transition: none; } }JavaScript
// Enrichr Library · https://enrichr.dev
// Tilted ring carousel: a row of 3:4 photos, each turned in 3D by 60 degrees per step away from the current one, so the
// row reads like a ring seen from inside: neighbours lean in, the next ones show their mirrored backs, and so on. The
// current photo faces you at full size with its caption sharp; the others are scaled to 0.85 and their captions blur out.
// Moving slides the row by one photo on a spring (0.8 s, bounce 0.2) while each photo turns and resizes on its own spring
// (1 s, bounce 0.1); captions fade and blur in 0.3 s. The springs are the same damped springs Motion builds from a
// duration and a bounce, solved exactly each frame and carrying their speed when you change direction mid-move. The row has
// two ends. Arrow buttons, the dots, a click on a photo, the left and right arrow keys, Home, End and a sideways swipe all
// move it; the photo in front is announced. Pauses off screen. Reduced motion: photos jump to their places and captions swap.
// mount(root, { turn (deg per step), side (size of the other photos, %), duration (seconds for the row spring),
// bounce (0 to 0.6), background, ink, accent })
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var box = root.querySelector(".ef-trc");
var stage = root.querySelector(".ef-trc__stage");
var row = root.querySelector(".ef-trc__row");
var slots = [].slice.call(root.querySelectorAll(".ef-trc__slot"));
var slides = slots.map(function (s) { return s.querySelector(".ef-trc__slide"); });
var dotsBox = root.querySelector(".ef-trc__dots");
var prev = root.querySelector(".ef-trc__prev"), next = root.querySelector(".ef-trc__next");
var live = root.querySelector(".ef-trc__live");
var n = Math.max(1, slots.length), active = 0, started = false;
var opts = {}, rowS = null, sp = [], dots = [], sw = 200;
var raf = 0, last = 0, visible = true, destroyed = false, drag = null, io = null, ro = null, offs = [];
function num(v, d) { v = Number(v); return isFinite(v) ? v : d; }
function cap(i) { var c = slides[i] && slides[i].querySelector(".ef-trc__cap"); return c ? c.textContent.trim() : ""; }
// Motion's spring from { duration, bounce }: damping ratio = 1 - bounce, natural frequency chosen so the motion has
// shrunk to 1/1000 of its size after the duration
function tune(s, dur, bounce) {
var z = Math.min(1, Math.max(0.05, 1 - bounce)), T = Math.min(10, Math.max(0.01, dur));
s.z = z;
s.w = z < 1 ? Math.log(z / (0.001 * Math.sqrt(1 - z * z))) / (z * T) : 9.2335 / T;
}
function spring(x, rd, rs) { return { x: x, v: 0, t: x, z: 1, w: 10, rd: rd, rs: rs }; }
// exact damped-spring step from the current offset and speed
function adv(s, dt) {
var d0 = s.x - s.t, v0 = s.v;
if (d0 === 0 && v0 === 0) return false;
var w = s.w, z = s.z, e, d, v;
if (z < 1) {
var wd = w * Math.sqrt(1 - z * z), c = Math.cos(wd * dt), sn = Math.sin(wd * dt);
e = Math.exp(-z * w * dt);
d = e * (d0 * c + ((v0 + z * w * d0) / wd) * sn);
v = e * (v0 * c - ((z * w * v0 + w * w * d0) / wd) * sn);
} else {
var B = v0 + w * d0;
e = Math.exp(-w * dt);
d = e * (d0 + B * dt); v = e * (v0 - w * B * dt);
}
s.x = s.t + d; s.v = v;
if (Math.abs(v) < s.rs && Math.abs(d) < s.rd) { s.x = s.t; s.v = 0; return false; }
return true;
}
function paint() {
row.style.transform = "translate3d(" + (-rowS.x * sw).toFixed(2) + "px,0,0)";
for (var i = 0; i < sp.length; i++) slides[i].style.transform = "scale(" + sp[i].s.x.toFixed(4) + ") rotateY(" + sp[i].r.x.toFixed(3) + "deg)";
}
function targets() {
rowS.t = active;
sp.forEach(function (p, i) { p.r.t = (active - i) * opts.turn; p.s.t = i === active ? 1 : opts.side / 100; });
if (reduce) { rowS.x = rowS.t; rowS.v = 0; sp.forEach(function (p) { p.r.x = p.r.t; p.s.x = p.s.t; p.r.v = p.s.v = 0; }); paint(); }
else kick();
}
function marks() {
slots.forEach(function (s, i) {
var on = i === active;
s.classList.toggle("is-active", on);
s.setAttribute("role", "group");
s.setAttribute("aria-roledescription", "slide");
s.setAttribute("aria-label", (i + 1) + " of " + n);
if (on) s.removeAttribute("aria-hidden"); else s.setAttribute("aria-hidden", "true");
});
dots.forEach(function (d, i) { d.setAttribute("aria-current", i === active ? "true" : "false"); });
prev.setAttribute("aria-disabled", active <= 0 ? "true" : "false");
next.setAttribute("aria-disabled", active >= n - 1 ? "true" : "false");
}
function toSlide(i, say) {
i = Math.max(0, Math.min(n - 1, i));
if (i === active) return;
active = i;
marks();
targets();
if (say) live.textContent = "Photo " + (active + 1) + " of " + n + (cap(active) ? ": " + cap(active) : "");
}
function loop(now) {
raf = 0;
var dt = Math.min(0.064, (now - last) / 1000 || 0); last = now;
if (!visible || document.hidden) return; // the IntersectionObserver restarts it
var moving = adv(rowS, dt);
for (var i = 0; i < sp.length; i++) { if (adv(sp[i].r, dt)) moving = true; if (adv(sp[i].s, dt)) moving = true; }
paint();
if (moving) raf = requestAnimationFrame(loop);
}
function kick() { if (!raf && !destroyed) { last = performance.now(); raf = requestAnimationFrame(loop); } }
function size() {
var W = Math.max(1, box.clientWidth), H = Math.max(1, box.clientHeight), md = W >= 768;
sw = Math.max(70, Math.min(md ? 200 : 120, (H - 150) * 0.75));
var sh = sw * 4 / 3, cy = Math.min(H / 2, H - 98 - sh / 2);
box.classList.toggle("is-md", md);
box.style.setProperty("--ef-trc-w", sw.toFixed(2) + "px");
row.style.left = (W / 2 - sw / 2).toFixed(2) + "px";
row.style.top = (cy - sh / 2).toFixed(2) + "px";
paint();
}
function buildDots() {
dotsBox.textContent = ""; dots = [];
slots.forEach(function (s, i) {
var b = document.createElement("button");
b.type = "button"; b.className = "ef-trc__dot";
b.setAttribute("aria-label", "Show photo " + (i + 1) + (cap(i) ? ": " + cap(i) : ""));
b.addEventListener("click", function () { toSlide(i, true); });
dotsBox.appendChild(b); dots.push(b);
});
}
function onKey(e) {
var k = e.key;
if (k === "ArrowRight") { e.preventDefault(); toSlide(active + 1, true); }
else if (k === "ArrowLeft") { e.preventDefault(); toSlide(active - 1, true); }
else if (k === "Home") { e.preventDefault(); toSlide(0, true); }
else if (k === "End") { e.preventDefault(); toSlide(n - 1, true); }
}
function down(e) { if (e.button !== undefined && e.button !== 0) return; drag = { x: e.clientX, id: e.pointerId, moved: false }; }
function up(e) {
if (!drag || e.pointerId !== drag.id) return;
var dx = e.clientX - drag.x, was = drag; drag = null;
if (Math.abs(dx) > 40) { was.moved = true; toSlide(active + (dx < 0 ? 1 : -1), true); }
else {
var f = e.target.closest && e.target.closest(".ef-trc__frame");
if (f) { var i = slots.indexOf(f.closest(".ef-trc__slot")); if (i >= 0) toSlide(i, true); }
}
}
function cancel() { drag = null; }
function onPrev() { toSlide(active - 1, true); }
function onNext() { toSlide(active + 1, true); }
box.addEventListener("keydown", onKey);
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);
stage.removeEventListener("pointerdown", down); stage.removeEventListener("pointerup", up); stage.removeEventListener("pointercancel", cancel);
prev.removeEventListener("click", onPrev); next.removeEventListener("click", onNext);
dotsBox.textContent = ""; dots = [];
});
function update(partial) {
opts = Object.assign({ turn: 60, side: 85, duration: 0.8, bounce: 0.2, background: "#fbf8f1", ink: "#0c1e18", accent: "#005236" }, opts, partial || {});
opts.turn = num(opts.turn, 60); opts.side = Math.max(10, num(opts.side, 85));
opts.duration = Math.max(0.1, num(opts.duration, 0.8)); opts.bounce = Math.min(0.9, Math.max(0, num(opts.bounce, 0.2)));
box.style.setProperty("--ef-trc-bg", opts.background);
box.style.setProperty("--ef-trc-ink", opts.ink);
box.style.setProperty("--ef-trc-acc", opts.accent);
if (destroyed) return;
if (!started) {
// first run: everything starts at rest in its place (Motion does not animate the first render)
started = true;
active = Math.min(3, n - 1); // the pen opens on the fourth photo
rowS = spring(active, 0.0005, 0.005);
sp = slides.map(function (s, i) { return { r: spring((active - i) * opts.turn, 0.02, 0.2), s: spring(i === active ? 1 : opts.side / 100, 0.0005, 0.005) }; });
buildDots();
marks();
}
tune(rowS, opts.duration, opts.bounce);
sp.forEach(function (p) { tune(p.r, opts.duration * 1.25, opts.bounce / 2); tune(p.s, opts.duration * 1.25, opts.bounce / 2); });
targets();
size();
}
update(o);
if ("IntersectionObserver" in window) { io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; if (visible) kick(); }); io.observe(box); }
if ("ResizeObserver" in window) { ro = new ResizeObserver(function () { if (!destroyed) size(); }); ro.observe(box); }
function onVis() { if (!document.hidden) kick(); }
document.addEventListener("visibilitychange", onVis);
offs.push(function () { document.removeEventListener("visibilitychange", onVis); });
return {
update: update,
destroy: function () {
destroyed = true; cancelAnimationFrame(raf); raf = 0;
offs.forEach(function (f) { f(); }); offs = [];
if (io) io.disconnect();
if (ro) ro.disconnect();
}
};
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"turn":60,"side":85,"duration":0.8,"bounce":0.2,"background":"#fbf8f1","ink":"#0c1e18","accent":"#005236"});How it works
The photos sit side by side in one row, each in its own 800 px perspective. Every photo is turned around its vertical axis by 60 degrees for each step it is away from the current one, so neighbours lean in, the next ones show their mirrored backs and the ones after face you again, which reads as a ring. The current photo faces you at full size and the rest are scaled to 0.85. The row slides by one photo on a spring set by a duration of 0.8 seconds and a bounce of 0.2; each photo turns and resizes on its own softer spring (1 second, bounce 0.1). These are the springs Motion builds from a duration and a bounce: damping ratio is one minus the bounce, and the stiffness is chosen so the motion has shrunk to a thousandth after the duration. They are solved exactly every frame and keep their speed if you change direction mid-move. Captions fade and unblur in 0.3 seconds.
Accessibility and performance
The carousel is a labelled region with a focusable photo row: Left, Right, Home and End move it. Previous and Next are real buttons, disabled at the ends, and each dot is a labelled button that marks the current photo. The photo in front is announced with its caption in a polite live region, and the others are hidden from assistive technology. With prefers-reduced-motion the photos jump to their places and the captions swap without blurring.
Each frame writes one transform on the row and one on each photo, which the browser composites without layout. The spring loop only runs while something is still moving, and it stops off screen and in hidden tabs. A ResizeObserver sizes the photos from the box: 200 px wide on wide boxes, 120 px on narrow ones.
| Section | Gallery |
|---|---|
| Techniques | Carousel, CSS 3D |
| 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 | Tilted 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.
More elements
FAQ
Why do some photos look mirrored?
Photos two steps away are turned 120 degrees, past side-on, so you see their backs. That mirrored look is what makes the strip read as a ring. Lower the turn below 45 degrees if you would rather never see a back.
How do I make it springier?
Raise Bounce for more overshoot, or Spring time for a slower settle. The photos use half the bounce and a quarter more time than the row, as in the original.
How do I change the photos and captions?
Edit the img src and alt and the caption text in each slide in the markup. The dots are built from the slides, so any count works.
Who took the photos?
Photos: Alvan Nee and fatty corgi on Unsplash, free to use under the Unsplash License.