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 "Image carousel 3D coverflow" for my site using the Enrichr Library element at https://enrichr.dev/elements/image-carousel-3d.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/image-carousel-3d.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:
- accent: #e6ff55
- angle: 52
- spread: 0.3
- scale: 1.18
- autoplay: 0
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/image-carousel-3d.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-ic">
<svg class="ef-ic__defs" width="0" height="0" aria-hidden="true" focusable="false">
<defs>
<symbol id="efic-p1" viewBox="1513.7 1453.9 322.3 322.3"><path fill="currentColor" d="M1821 1761.2h-292.3v-111.6c0-8 6.5-14.5 14.5-14.5 4.2 0 8.2 1.9 11 5.1 7.8 8.6 19.7 13.6 32.7 11.9 16.8-2.3 30.1-16 32-32.7 2.5-22.6-15.1-41.6-37.1-41.6-11 0-20.9 4.7-27.7 12.3-2.8 3.1-6.7 4.9-10.9 4.9-8 0-14.5-6.4-14.5-14.4v-111.7h292.3v111.7c0 8-6.4 14.4-14.5 14.4-4.2 0-8.1-1.8-10.9-5-7.9-8.6-19.7-13.6-32.7-11.9-16.8 2.2-30.1 15.9-32 32.7-2.5 22.6 15.1 41.6 37.1 41.6 11 0 20.8-4.8 27.7-12.3 2.8-3.1 6.7-5 10.8-5 8.1 0 14.5 6.5 14.5 14.5z"/></symbol>
<symbol id="efic-p4" viewBox="1423.36 860.8 503.07 322.3"><path fill="currentColor" d="M1821 875.8v111.6c0 8 6.5 14.5 14.5 14.5 4.2 0 8.1-1.9 10.9-5 6.8-7.5 16.7-12.3 27.7-12.3 22 0 39.6 19 37.1 41.6-1.9 16.7-15.2 30.5-32 32.7-13 1.7-24.8-3.3-32.7-11.9-2.8-3.2-6.8-5-11-5-8 0-14.5 6.4-14.5 14.4v111.7h-111.6c-8 0-14.5-6.5-14.5-14.5 0-4.2 1.9-8.2 5-11 8.7-7.9 13.7-19.7 12-32.7-2.3-16.8-16-30.1-32.8-32-22.5-2.5-41.5 15.1-41.5 37.1 0 11 4.7 20.9 12.3 27.7 3.1 2.8 4.9 6.7 4.9 10.8v0.1c0 8-6.4 14.5-14.5 14.5h-111.6v-111.7c0-8-6.4-14.4-14.5-14.4-4.1 0-8 1.8-10.8 4.9-6.9 7.6-16.7 12.3-27.7 12.3-22 0-39.6-19-37.1-41.6 1.9-16.7 15.2-30.4 32-32.7 13-1.7 24.8 3.3 32.7 11.9 2.8 3.2 6.7 5.1 10.9 5.1 8.1 0 14.5-6.5 14.5-14.5v-111.6z"/></symbol>
<symbol id="efic-p8" viewBox="1513.7 166.86 322.3 503.06"><path fill="currentColor" d="M1528.7 564.5v-111.6c0-8 6.5-14.5 14.5-14.5 4.2 0 8.2 1.9 11 5 7.8 8.7 19.7 13.7 32.7 11.9 16.8-2.2 30.1-15.9 32-32.7 2.5-22.5-15.1-41.5-37.1-41.5-11 0-20.9 4.7-27.7 12.3-2.8 3.1-6.7 4.9-10.9 4.9-8 0-14.5-6.5-14.5-14.5v-111.6h111.6c8.1 0 14.5-6.5 14.5-14.5 0-4.1-1.8-8.1-4.9-10.9-7.6-6.8-12.3-16.6-12.3-27.6 0-22 19-39.6 41.5-37.1 16.8 1.8 30.5 15.2 32.8 32 1.7 12.9-3.3 24.8-12 32.7-3.1 2.8-5 6.7-5 10.9 0 8 6.5 14.5 14.5 14.5h111.6v111.6c0 8-6.4 14.5-14.5 14.5-4.2 0-8.1-1.9-10.9-5-7.9-8.7-19.7-13.6-32.7-11.9-16.8 2.2-30.1 15.9-32 32.7-2.5 22.5 15.1 41.6 37.1 41.6 11 0 20.8-4.8 27.7-12.3 2.8-3.1 6.7-5 10.8-5 8.1 0 14.5 6.5 14.5 14.5v111.6h-111.6c-8 0-14.5 6.5-14.5 14.5 0 4.2 1.9 8.1 5 10.9 7.5 6.8 12.3 16.7 12.3 27.7 0 22-19 39.5-41.6 37.1-16.8-1.9-30.5-15.3-32.7-32-1.7-13 3.3-24.9 11.9-32.7 3.1-2.9 5-6.8 5-11 0-8-6.4-14.5-14.5-14.5z"/></symbol>
</defs>
</svg>
<div class="ef-ic__wrap" tabindex="0" role="region" aria-roledescription="carousel" aria-label="3D puzzle-piece carousel. Use the left and right arrow keys, the buttons, or drag to change card.">
<div class="ef-ic__stage"><div class="ef-ic__floor"></div><div class="ef-ic__cards"></div></div>
<div class="ef-ic__bar">
<button type="button" class="ef-ic__btn ef-ic__prev" aria-label="Previous card"><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M15 5l-7 7 7 7" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
<div class="ef-ic__cap" aria-live="polite"><span class="ef-ic__num"></span><span class="ef-ic__ttl"></span><span class="ef-ic__sub"></span></div>
<button type="button" class="ef-ic__btn ef-ic__next" aria-label="Next card"><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M9 5l7 7-7 7" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
</div>
</div>
</div>CSS
/* Enrichr Library · https://enrichr.dev */
.ef-ic { width: 100%; height: 100%; }
.ef-ic__defs { position: absolute; width: 0; height: 0; overflow: hidden; }
.ef-ic__wrap { --ef-acc: #e6ff55; position: relative; box-sizing: border-box; width: 100%; height: 100%; min-height: 26rem; display: flex; flex-direction: column; overflow: hidden; border-radius: 1rem; background: radial-gradient(ellipse at 50% 38%, #005236 0%, #0c1e18 70%); color: #fbf8f1; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; outline: none; }
.ef-ic__wrap:focus-visible { box-shadow: inset 0 0 0 2px var(--ef-acc); }
.ef-ic__stage { position: relative; flex: 1 1 auto; min-height: 0; perspective: 1100px; perspective-origin: 50% 42%; cursor: grab; touch-action: pan-y; user-select: none; -webkit-user-select: none; }
.ef-ic__stage.is-drag { cursor: grabbing; }
.ef-ic__floor { position: absolute; left: 50%; bottom: 7%; width: 62%; height: 14%; transform: translateX(-50%); border-radius: 50%; background: radial-gradient(ellipse at 50% 50%, rgba(0, 0, 0, 0.55), transparent 70%); pointer-events: none; }
.ef-ic__cards { position: absolute; inset: 0; transform-style: preserve-3d; }
.ef-ic__card { position: absolute; left: 50%; top: 46%; width: min(11rem, 36%); aspect-ratio: 4 / 5; margin: 0; border-radius: 1rem; overflow: hidden; transform-origin: 50% 50%; backface-visibility: hidden; -webkit-backface-visibility: hidden; box-shadow: 0 18px 28px -16px rgba(0, 0, 0, 0.7); will-change: transform; }
.ef-ic__card.is-active { box-shadow: 0 0 0 2px var(--ef-acc), 0 30px 40px -18px rgba(0, 0, 0, 0.8); }
.ef-ic__art { position: absolute; inset: 0; }
.ef-ic__art svg { position: absolute; left: 14%; top: 24%; width: 72%; height: 52%; display: block; overflow: visible; }
.ef-ic__idx { position: absolute; left: 0.9rem; top: 0.8rem; font-size: 0.7rem; font-weight: 800; letter-spacing: 0.16em; opacity: 0.85; }
.ef-ic__lbl { position: absolute; left: 0.9rem; right: 0.9rem; bottom: 0.85rem; font-size: 0.95rem; font-weight: 800; letter-spacing: -0.01em; }
.ef-ic__shade { position: absolute; inset: 0; background: #0c1e18; opacity: 0; pointer-events: none; }
.ef-ic__bar { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 0.4rem 1.1rem 1rem; }
.ef-ic__cap { flex: 1; min-width: 0; text-align: center; display: flex; flex-direction: column; align-items: center; gap: 0.1rem; }
.ef-ic__num { font-size: 0.68rem; font-weight: 700; letter-spacing: 0.18em; color: var(--ef-acc); }
.ef-ic__ttl { font-size: 1.05rem; font-weight: 800; letter-spacing: -0.01em; }
.ef-ic__sub { font-size: 0.74rem; opacity: 0.7; }
.ef-ic__btn { flex: 0 0 auto; width: 2.5rem; height: 2.5rem; border-radius: 50%; border: 1.5px solid rgba(251, 248, 241, 0.35); background: transparent; color: #fbf8f1; display: grid; place-items: center; cursor: pointer; transition: background 200ms, color 200ms, border-color 200ms; }
.ef-ic__btn:hover { background: var(--ef-acc); color: #0c1e18; border-color: var(--ef-acc); }
.ef-ic__btn:focus-visible { outline: 2px solid var(--ef-acc); outline-offset: 2px; }JavaScript
// Enrichr Library · https://enrichr.dev
// Image carousel 3D: a coverflow row. The centre card sits flat, enlarged and outlined; the cards either side swing back on a hinge and
// recede in perspective, dimming as they go. Drag or swipe, arrow keys, the buttons, a sideways wheel, or click a side card.
// Reduced motion: no easing, no autoplay; the row steps instantly. The scrolling loop stops itself once the row has settled.
// mount(root, { accent, angle (20-75 deg), spread (0.15-0.6), scale (1-1.5), autoplay (0-8 seconds, 0 = off) })
function mount(root, o) {
var mq = window.matchMedia("(prefers-reduced-motion: reduce)");
var wrap = root.querySelector(".ef-ic__wrap");
var stage = root.querySelector(".ef-ic__stage");
var holder = root.querySelector(".ef-ic__cards");
var prevB = root.querySelector(".ef-ic__prev"), nextB = root.querySelector(".ef-ic__next");
var cNum = root.querySelector(".ef-ic__num"), cTtl = root.querySelector(".ef-ic__ttl"), cSub = root.querySelector(".ef-ic__sub");
var DATA = [["Starter kit", "Where every set begins"], ["Edge match", "Straight sides, clean joins"], ["Slot in", "One tab, one gap"], ["Click fit", "Snaps into place"], ["Lock step", "Holds the row together"], ["Full set", "Every piece, one picture"], ["Final piece", "The one that completes it"]];
var COLS = [["#005236", "#e6ff55"], ["#0a6b46", "#fbf8f1"], ["#2f7d5b", "#c6f26b"], ["#c6f26b", "#005236"], ["#fbf8f1", "#0a6b46"], ["#0c1e18", "#c6f26b"], ["#e6ff55", "#0c1e18"]];
var SYMS = ["efic-p1", "efic-p4", "efic-p8", "efic-p4", "efic-p1", "efic-p8", "efic-p1"];
var N = DATA.length;
var opts = {}, cards = [], shades = [], offs = [], raf = 0, timer = 0, onScreen = true, io = null;
var pos = 0, target = 0, last = 0, drag = null, cardW = 200, hovering = false, wheelAcc = 0, wheelAt = 0, active = -1;
function mod(a, n) { return ((a % n) + n) % n; }
function build() {
holder.textContent = "";
cards = []; shades = [];
for (var i = 0; i < N; i++) {
var c = document.createElement("div");
c.className = "ef-ic__card";
c.setAttribute("aria-hidden", "true");
var pair = COLS[i % COLS.length];
var art = document.createElement("div");
art.className = "ef-ic__art";
art.style.background = i === 6 ? opts.accent : pair[0];
art.style.color = pair[1];
var svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
var use = document.createElementNS("http://www.w3.org/2000/svg", "use");
use.setAttribute("href", "#" + SYMS[i]);
svg.appendChild(use);
var ix = document.createElement("span"); ix.className = "ef-ic__idx"; ix.textContent = String(i + 1).padStart(2, "0");
var lb = document.createElement("span"); lb.className = "ef-ic__lbl"; lb.textContent = DATA[i][0];
var sh = document.createElement("span"); sh.className = "ef-ic__shade";
art.appendChild(svg); art.appendChild(ix); art.appendChild(lb); art.appendChild(sh);
c.appendChild(art);
holder.appendChild(c);
cards.push(c); shades.push(sh);
}
active = -1;
measure();
render();
}
function measure() { cardW = cards[0] ? cards[0].offsetWidth || 200 : 200; }
function render() {
var ang = opts.angle * Math.PI / 180;
var step = cardW * opts.spread;
var push = cardW * (0.5 * opts.scale + 0.5 * Math.cos(ang) + 0.16);
var act = mod(Math.round(pos), N);
for (var i = 0; i < N; i++) {
var d = mod(i - pos + N / 2, N) - N / 2;
var ad = Math.abs(d), s = d < 0 ? -1 : 1, t = Math.min(1, ad);
var x = s * (push * t + step * Math.max(0, ad - 1));
var z = -t * cardW * 0.75 - Math.max(0, ad - 1) * cardW * 0.28 + (1 - t) * cardW * 0.12;
var sc = 1 + (opts.scale - 1) * (1 - t);
var el = cards[i];
el.style.transform = "translate(-50%,-50%) translateX(" + x.toFixed(1) + "px) translateZ(" + z.toFixed(1) + "px) rotateY(" + (-s * opts.angle * t).toFixed(2) + "deg) scale(" + sc.toFixed(3) + ")";
el.style.zIndex = String(100 - Math.round(ad * 10));
el.style.opacity = ad > 2.6 ? String(Math.max(0, 1 - (ad - 2.6) / 0.6)) : "1";
el.style.visibility = ad > 3.2 ? "hidden" : "visible";
shades[i].style.opacity = (t * 0.4).toFixed(3);
el.classList.toggle("is-active", i === act);
}
if (act !== active) {
active = act;
cNum.textContent = String(act + 1).padStart(2, "0") + " / " + String(N).padStart(2, "0");
cTtl.textContent = DATA[act][0];
cSub.textContent = DATA[act][1];
}
}
function frame(now) {
raf = 0;
var dt = Math.min(0.05, (now - last) / 1000 || 0.016);
last = now;
if (!drag) {
var diff = target - pos;
if (mq.matches || Math.abs(diff) < 0.0008) pos = target;
else pos += diff * (1 - Math.exp(-dt * 9));
}
render();
if (drag || pos !== target) raf = requestAnimationFrame(frame);
}
function run() {
if (raf || !onScreen) return;
last = performance.now();
raf = requestAnimationFrame(frame);
}
function go(to) {
target = Math.round(to);
if (mq.matches) pos = target;
run();
arm();
}
function step(n) { go(Math.round(target) + n); }
// autoplay, paused on hover/focus/drag and under reduced motion
function arm() {
clearTimeout(timer);
timer = 0;
if (!(opts.autoplay > 0) || mq.matches || !onScreen) return;
timer = setTimeout(function () {
if (!hovering && !drag && !wrap.contains(document.activeElement) && !document.hidden) step(1); else arm();
}, opts.autoplay * 1000);
}
function down(e) {
if (e.pointerType === "mouse" && e.button !== 0) return;
drag = { id: e.pointerId, x: e.clientX, y: e.clientY, sx: e.clientX, t: performance.now(), v: 0, moved: false };
try { stage.setPointerCapture(e.pointerId); } catch (er) {}
stage.classList.add("is-drag");
run();
}
function move(e) {
if (!drag || e.pointerId !== drag.id) return;
var now = performance.now();
var dx = e.clientX - drag.x;
if (Math.abs(e.clientX - drag.sx) > 6) drag.moved = true;
var per = cardW * 0.6;
var dp = -dx / per;
pos += dp; target = pos;
var dts = Math.max(0.008, (now - drag.t) / 1000);
drag.v = drag.v * 0.6 + (dp / dts) * 0.4;
drag.x = e.clientX; drag.t = now;
run();
}
function up(e) {
if (!drag || e.pointerId !== drag.id) return;
var d = drag; drag = null;
stage.classList.remove("is-drag");
try { stage.releasePointerCapture(e.pointerId); } catch (er) {}
if (!d.moved) {
// a click: bring a side card to the centre
var el = document.elementFromPoint(e.clientX, e.clientY);
var c = el && el.closest ? el.closest(".ef-ic__card") : null;
var i = c ? cards.indexOf(c) : -1;
if (i >= 0) { var dd = mod(i - Math.round(pos) + N / 2, N) - N / 2; target = Math.round(pos); go(target + dd); return; }
}
var fling = performance.now() - d.t < 90 && !mq.matches ? d.v * 0.18 : 0;
fling = Math.max(-2, Math.min(2, fling));
go(pos + fling);
}
// wheel is captured only while the pointer is over the carousel, and only for sideways scrolling so the page still scrolls vertically
function wheel(e) {
var dx = e.deltaX;
if (e.shiftKey && !dx) dx = e.deltaY;
else if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return;
e.preventDefault();
var now = performance.now();
if (now - wheelAt > 220) wheelAcc = 0;
wheelAcc += dx;
if (Math.abs(wheelAcc) > 40 && now - wheelAt > 220) { step(wheelAcc > 0 ? 1 : -1); wheelAcc = 0; wheelAt = now; }
}
function key(e) {
if (e.key === "ArrowRight") { e.preventDefault(); step(1); }
else if (e.key === "ArrowLeft") { e.preventDefault(); step(-1); }
else if (e.key === "Home") { e.preventDefault(); go(Math.round(target) - mod(Math.round(target), N)); }
}
function pb() { step(-1); }
function nb() { step(1); }
function enter() { hovering = true; }
function leave() { hovering = false; }
function resize() { measure(); render(); }
function attach() {
stage.addEventListener("pointerdown", down);
stage.addEventListener("pointermove", move);
stage.addEventListener("pointerup", up);
stage.addEventListener("pointercancel", up);
stage.addEventListener("wheel", wheel, { passive: false });
wrap.addEventListener("keydown", key);
wrap.addEventListener("pointerenter", enter);
wrap.addEventListener("pointerleave", leave);
prevB.addEventListener("click", pb);
nextB.addEventListener("click", nb);
window.addEventListener("resize", resize);
offs.push(function () {
stage.removeEventListener("pointerdown", down);
stage.removeEventListener("pointermove", move);
stage.removeEventListener("pointerup", up);
stage.removeEventListener("pointercancel", up);
stage.removeEventListener("wheel", wheel);
wrap.removeEventListener("keydown", key);
wrap.removeEventListener("pointerenter", enter);
wrap.removeEventListener("pointerleave", leave);
prevB.removeEventListener("click", pb);
nextB.removeEventListener("click", nb);
window.removeEventListener("resize", resize);
});
if (window.IntersectionObserver) {
io = new IntersectionObserver(function (en) { onScreen = en[0].isIntersecting; if (onScreen) { run(); arm(); } else clearTimeout(timer); });
io.observe(wrap);
}
}
function update(next) {
var prev = opts;
opts = Object.assign({ accent: "#e6ff55", angle: 52, spread: 0.3, scale: 1.18, autoplay: 0 }, opts, next || {});
opts.angle = Math.max(15, Math.min(80, +opts.angle));
opts.spread = Math.max(0.12, Math.min(0.7, +opts.spread));
opts.scale = Math.max(1, Math.min(1.6, +opts.scale));
opts.autoplay = Math.max(0, Math.min(10, +opts.autoplay));
wrap.style.setProperty("--ef-acc", opts.accent);
if (prev.accent !== opts.accent) build(); else render();
arm();
}
attach();
update(o);
return {
update: update,
destroy: function () {
offs.forEach(function (f) { f(); }); offs = [];
if (raf) cancelAnimationFrame(raf);
raf = 0; clearTimeout(timer); timer = 0; onScreen = false;
if (io) io.disconnect();
holder.textContent = ""; cards = []; shades = [];
}
};
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"accent":"#e6ff55","angle":52,"spread":0.3,"scale":1.18,"autoplay":0});How it works
One number, pos, says which card is in the centre. For every card the script takes its signed distance d from pos, wrapping around so the row loops. A card at d = 0 is flat and scaled up; as |d| grows to 1 it turns by the side angle on rotateY, moves outward, and drops back on translateZ; beyond 1 it only recedes and dims. Because everything is a continuous function of d, dragging feels smooth and the settle is just pos easing to a whole number. Release adds a short fling, and keys, buttons, a sideways wheel and a click on a side card all change the target.
Accessibility and performance
The carousel is a labelled region you can focus; left and right arrows, Home and the two buttons change card, and the caption below is announced politely. The cards are decorative, so the caption carries the name. Reduced motion removes easing and autoplay and steps instantly. Autoplay also pauses on hover and while focus is inside. The wheel is only captured for sideways scrolling, so vertical page scroll still works.
Seven cards, each moved with transform and opacity only. The loop runs just while the row is dragged or settling and sleeps otherwise; it also pauses when the element is off screen.
| Section | Gallery |
|---|---|
| Techniques | Carousel, CSS 3D, Pointer interaction |
| 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 | 3D Image Carousel by Osmo on osmo.supply. 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 is this different from the other carousels?
It is a straight coverflow row: a flat centred card with angled side cards on a shared hinge angle, enlarged active card, caption bar and a looping row. It has no ring, arc or parallax layers.
Can I use photos?
Yes. Replace the art layer of each card with an img. The card is a plain div, so the angle, shade and enlarge all still work.
How do I change how far the sides turn?
Use the angle and spacing controls. Angle is the rotateY of the neighbours; spacing is how tightly the farther cards stack.