Copied!
Gallery

Parallax photo carousel

A draggable 3D carousel of gradient photo cards with parallax inner layers, inertia and spring snap, plus arrow keys, buttons and dots.

← Library

Element · Vanilla JS + CSSUpdated November 6, 2026

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

Read the machine-readable spec and source at https://enrichr.dev/elements/parallax-photo-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:
- accent: #e6ff55
- count: 6
- tilt: 28
- depth: 140
- parallax: 1

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/parallax-photo-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-ppc">
  <div class="ef-ppc__wrap">
    <div class="ef-ppc__stage" tabindex="0" role="group" aria-roledescription="carousel" aria-label="Photo carousel. Drag, swipe or use the left and right arrow keys."><div class="ef-ppc__track"></div></div>
    <div class="ef-ppc__bar">
      <button type="button" class="ef-ppc__btn ef-ppc__prev" aria-label="Previous photo"><svg viewBox="0 0 24 24" width="18" height="18" 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-ppc__dots"></div>
      <button type="button" class="ef-ppc__btn ef-ppc__next" aria-label="Next photo"><svg viewBox="0 0 24 24" width="18" height="18" 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>
    <span class="ef-ppc__live" aria-live="polite"></span>
  </div>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-ppc { width: 100%; }
.ef-ppc__wrap { --pp: 1; box-sizing: border-box; width: 100%; min-height: 21rem; padding: 1rem 1rem 0.8rem; border-radius: 1rem; display: flex; flex-direction: column; gap: 0.7rem; overflow: hidden; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; color: #fbf8f1; }
.ef-ppc__stage { position: relative; flex: 1; min-height: 15.5rem; perspective: 900px; overflow: hidden; touch-action: pan-y; cursor: grab; user-select: none; -webkit-user-select: none; border-radius: 0.8rem; outline: none; }
.ef-ppc__stage.is-drag { cursor: grabbing; }
.ef-ppc__stage:focus-visible { box-shadow: 0 0 0 2px var(--ef-acc, #e6ff55); }
.ef-ppc__track { position: absolute; inset: 0; transform-style: preserve-3d; }
.ef-ppc__card { --d: 0; position: absolute; left: 50%; top: 50%; width: min(46%, 13rem); aspect-ratio: 3 / 4; border-radius: 0.9rem; overflow: hidden; background: #0c1e18; box-shadow: 0 18px 30px -14px rgba(0, 0, 0, 0.7); will-change: transform; backface-visibility: hidden; }
.ef-ppc__l { position: absolute; top: 0; bottom: 0; left: -30%; width: 160%; pointer-events: none; }
.ef-ppc__l svg { display: block; width: 100%; height: 100%; }
.ef-ppc__l0 { transform: translate3d(calc(var(--d) * var(--pp) * -1.5%), 0, 0); }
.ef-ppc__l1 { transform: translate3d(calc(var(--d) * var(--pp) * -3.5%), 0, 0); }
.ef-ppc__l2 { transform: translate3d(calc(var(--d) * var(--pp) * -6%), 0, 0); }
.ef-ppc__l3 { transform: translate3d(calc(var(--d) * var(--pp) * -9.5%), 0, 0); }
.ef-ppc__l4 { transform: translate3d(calc(var(--d) * var(--pp) * -14%), 0, 0); }
.ef-ppc__shade { position: absolute; inset: 0; pointer-events: none; background: linear-gradient(180deg, rgba(0, 0, 0, 0.1), transparent 30%, transparent 55%, rgba(0, 0, 0, 0.62)); }
.ef-ppc__txt { position: absolute; left: 0.85rem; right: 0.85rem; bottom: 0.8rem; pointer-events: none; transform: translate3d(calc(var(--d) * var(--pp) * 4%), 0, 0); }
.ef-ppc__t { display: block; font-size: 1.05rem; font-weight: 800; letter-spacing: -0.01em; line-height: 1.15; text-shadow: 0 2px 10px rgba(0, 0, 0, 0.4); }
.ef-ppc__s { display: block; margin-top: 0.2rem; font-size: 0.7rem; letter-spacing: 0.06em; opacity: 0.8; }
.ef-ppc__n { position: absolute; top: 0.75rem; left: 0.85rem; font-size: 0.66rem; font-weight: 700; letter-spacing: 0.14em; opacity: 0.85; pointer-events: none; }
.ef-ppc__bar { display: flex; align-items: center; justify-content: center; gap: 0.9rem; }
.ef-ppc__btn { width: 2.1rem; height: 2.1rem; border-radius: 50%; border: 1px solid rgba(251, 248, 241, 0.3); background: transparent; color: inherit; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; padding: 0; transition: background 200ms, color 200ms, opacity 200ms; }
.ef-ppc__btn:hover:not(:disabled) { background: var(--ef-acc, #e6ff55); color: #0c1e18; border-color: transparent; }
.ef-ppc__btn:focus-visible { outline: 2px solid var(--ef-acc, #e6ff55); outline-offset: 2px; }
.ef-ppc__btn:disabled { opacity: 0.35; cursor: default; }
.ef-ppc__dots { display: flex; gap: 0.4rem; }
.ef-ppc__dot { width: 0.45rem; height: 0.45rem; border-radius: 999px; padding: 0; border: 0; background: rgba(251, 248, 241, 0.35); cursor: pointer; transition: width 250ms, background 250ms; }
.ef-ppc__dot[aria-current="true"] { width: 1.3rem; background: var(--ef-acc, #e6ff55); }
.ef-ppc__dot:focus-visible { outline: 2px solid var(--ef-acc, #e6ff55); outline-offset: 2px; }
.ef-ppc__live { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) { .ef-ppc__btn, .ef-ppc__dot { transition: none; } }

JavaScript

// Enrichr Library · https://enrichr.dev
// Parallax photo carousel: gradient "photo" cards on a 3D perspective rail. Drag or swipe with inertia, a spring snaps to the nearest card,
// and each card's inner layers (sky, sun, ridges) slide at different speeds so the scene has depth. Arrow keys, Home/End, buttons and dots work too.
// Reduced motion: no spring and no inertia, selecting a card jumps straight to it. The loop only runs while something is moving.
// mount(root, { background, accent, count (3-9), tilt (deg), depth (px), parallax (0-2) })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var wrap = root.querySelector(".ef-ppc__wrap");
  var stage = root.querySelector(".ef-ppc__stage");
  var track = root.querySelector(".ef-ppc__track");
  var dotsEl = root.querySelector(".ef-ppc__dots");
  var prevB = root.querySelector(".ef-ppc__prev");
  var nextB = root.querySelector(".ef-ppc__next");
  var live = root.querySelector(".ef-ppc__live");
  var opts = {}, cards = [], dots = [], offs = [], fixed = [];
  var pos = 0, vel = 0, target = 0, step = 200, raf = 0, last = 0;
  var dragging = false, lastX = 0, lastT = 0, travelled = 0, downEl = null, ro = null;
  var DATA = [["Tidal", "Field notes"], ["Ember", "Studio series"], ["Moss", "Quiet objects"], ["Dusk", "Night shift"], ["Arch", "Form study"], ["Grain", "Print run"], ["Haze", "Soft light"], ["Loop", "Motion kit"], ["Drift", "Slow travel"]];
  var PAL = [["#0a6b46", "#0c1e18", "#00402a", "#005236", "#0c1e18"], ["#1d8a5b", "#07261b", "#0a6b46", "#00402a", "#07130e"], ["#0c1e18", "#005236", "#0a3d2b", "#0c2a1e", "#050f0b"], ["#2b9a6b", "#0c1e18", "#087a50", "#005236", "#0a1a14"], ["#064d33", "#0c1e18", "#0a5c3d", "#043a27", "#06120d"]];

  function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
  function ridge(seed, base, amp, fill) {
    var p = "M0 60 L0 " + base, i, x, y;
    for (i = 0; i <= 10; i++) { x = i * 10; y = base + amp * Math.sin(i * 0.9 + seed * 1.7) + amp * 0.5 * Math.sin(i * 2.1 + seed); p += " L" + x + " " + y.toFixed(1); }
    return '<svg viewBox="0 0 100 60" preserveAspectRatio="none" aria-hidden="true"><path d="' + p + ' L100 60 Z" fill="' + fill + '"/></svg>';
  }
  function layer(cls, html, bg) {
    var l = document.createElement("span");
    l.className = "ef-ppc__l " + cls;
    if (bg) l.style.background = bg;
    if (html) l.innerHTML = html; // static SVG built above, no user text
    return l;
  }
  function clear() { offs.forEach(function (f) { f(); }); offs = []; }
  function stop() { if (raf) cancelAnimationFrame(raf); raf = 0; }
  function on(el, ev, fn, opt) { el.addEventListener(ev, fn, opt); offs.push(function () { el.removeEventListener(ev, fn, opt); }); }

  function measure() { step = Math.max(80, stage.clientWidth * 0.34); render(); }
  function render() {
    var n = cards.length, i, d, ad, c, dc;
    for (i = 0; i < n; i++) {
      c = cards[i];
      d = i - pos; ad = Math.abs(d);
      dc = clamp(d, -2.5, 2.5);
      c.style.setProperty("--d", dc.toFixed(3));
      c.style.zIndex = String(100 - Math.round(ad * 10));
      c.style.opacity = clamp(1 - Math.max(0, ad - 1.2) * 0.45, 0, 1).toFixed(2);
      c.style.transform = "translate(-50%,-50%) translate3d(" + (d * step).toFixed(1) + "px,0," + (-Math.min(ad, 3) * opts.depth).toFixed(1) + "px) rotateY(" + (-dc * opts.tilt).toFixed(2) + "deg) scale(" + (1 - Math.min(ad, 2) * 0.04).toFixed(3) + ")";
    }
  }
  function tick(t) {
    var dt = Math.min(0.05, (t - last) / 1000); last = t;
    if (!dragging) {
      var K = 95, C = 17;
      vel += ((target - pos) * K - vel * C) * dt;
      pos += vel * dt;
      if (Math.abs(target - pos) < 0.0008 && Math.abs(vel) < 0.002) { pos = target; vel = 0; render(); raf = 0; return; }
    }
    render();
    raf = requestAnimationFrame(tick);
  }
  function kick() { if (!raf) { last = performance.now(); raf = requestAnimationFrame(tick); } }
  function ui() {
    var i = Math.round(target);
    dots.forEach(function (d, k) { d.setAttribute("aria-current", k === i ? "true" : "false"); });
    cards.forEach(function (c, k) { c.setAttribute("aria-label", (k + 1) + " of " + cards.length + ": " + DATA[k % DATA.length][0] + (k === i ? " (current)" : "")); });
    prevB.disabled = i <= 0; nextB.disabled = i >= cards.length - 1;
    live.textContent = "Photo " + (i + 1) + " of " + cards.length + ": " + DATA[i % DATA.length][0];
  }
  function goTo(i) {
    target = clamp(Math.round(i), 0, cards.length - 1);
    if (reduce) { pos = target; vel = 0; render(); } else kick();
    ui();
  }
  function up() {
    if (!dragging) return;
    dragging = false; stage.classList.remove("is-drag");
    if (travelled < 5 && downEl) { goTo(cards.indexOf(downEl)); return; }
    if (performance.now() - lastT > 90) vel = 0;
    target = clamp(Math.round(pos + vel * 0.18), Math.round(pos) - 2, Math.round(pos) + 2);
    target = clamp(target, 0, cards.length - 1);
    if (reduce) { pos = target; vel = 0; render(); } else kick();
    ui();
  }
  function build() {
    clear(); stop();
    track.textContent = ""; dotsEl.textContent = "";
    cards = []; dots = [];
    for (var i = 0; i < opts.count; i++) (function (i) {
      var pal = PAL[i % PAL.length];
      var c = document.createElement("div");
      c.className = "ef-ppc__card";
      c.setAttribute("role", "group"); c.setAttribute("aria-roledescription", "slide");
      c.appendChild(layer("ef-ppc__l0", "", "linear-gradient(180deg, " + pal[0] + ", " + pal[1] + ")"));
      var sx = 30 + (i * 23) % 40;
      c.appendChild(layer("ef-ppc__l1", "", "radial-gradient(circle at " + sx + "% 30%, " + opts.accent + " 0 9%, transparent 9.5%)"));
      c.appendChild(layer("ef-ppc__l2", ridge(i, 36, 5, pal[2])));
      c.appendChild(layer("ef-ppc__l3", ridge(i + 3, 44, 5, pal[3])));
      c.appendChild(layer("ef-ppc__l4", ridge(i + 7, 52, 4, pal[4])));
      var sh = document.createElement("span"); sh.className = "ef-ppc__shade";
      var nn = document.createElement("span"); nn.className = "ef-ppc__n"; nn.textContent = String(i + 1).padStart(2, "0");
      var tx = document.createElement("span"); tx.className = "ef-ppc__txt";
      var t = document.createElement("span"); t.className = "ef-ppc__t"; t.textContent = DATA[i % DATA.length][0];
      var s = document.createElement("span"); s.className = "ef-ppc__s"; s.textContent = DATA[i % DATA.length][1];
      tx.appendChild(t); tx.appendChild(s);
      c.appendChild(sh); c.appendChild(nn); c.appendChild(tx);
      track.appendChild(c); cards.push(c);
      var d = document.createElement("button");
      d.type = "button"; d.className = "ef-ppc__dot"; d.setAttribute("aria-label", "Go to photo " + (i + 1));
      on(d, "click", function () { goTo(i); });
      dotsEl.appendChild(d); dots.push(d);
    })(i);
    on(prevB, "click", function () { goTo(Math.round(target) - 1); });
    on(nextB, "click", function () { goTo(Math.round(target) + 1); });
    on(stage, "keydown", function (e) {
      var k = e.key;
      if (k === "ArrowRight") goTo(Math.round(target) + 1);
      else if (k === "ArrowLeft") goTo(Math.round(target) - 1);
      else if (k === "Home") goTo(0);
      else if (k === "End") goTo(cards.length - 1);
      else return;
      e.preventDefault();
    });
    on(stage, "pointerdown", function (e) {
      if (e.button !== undefined && e.button !== 0) return;
      dragging = true; vel = 0; travelled = 0; lastX = e.clientX; lastT = performance.now();
      downEl = e.target && e.target.closest ? e.target.closest(".ef-ppc__card") : null;
      stage.classList.add("is-drag");
      try { stage.setPointerCapture(e.pointerId); } catch (x) {}
      kick();
    });
    on(stage, "pointermove", function (e) {
      if (!dragging) return;
      var now = performance.now(), dx = e.clientX - lastX, dts = Math.max(0.001, (now - lastT) / 1000);
      travelled += Math.abs(dx);
      pos = clamp(pos - dx / step, -0.35, cards.length - 0.65);
      vel = vel * 0.6 + (-dx / step / dts) * 0.4;
      lastX = e.clientX; lastT = now;
      render();
    });
    on(stage, "pointerup", up);
    on(stage, "pointercancel", up);
    target = clamp(target, 0, cards.length - 1);
    pos = target; vel = 0;
    ui(); measure();
  }
  function update(next) {
    var prev = opts;
    opts = Object.assign({ background: "#0c1e18", accent: "#e6ff55", count: 6, tilt: 28, depth: 140, parallax: 1 }, opts, next || {});
    opts.count = Math.max(3, Math.min(9, Math.round(opts.count)));
    wrap.style.background = opts.background;
    wrap.style.setProperty("--ef-acc", opts.accent);
    wrap.style.setProperty("--pp", String(opts.parallax));
    if (prev.count !== opts.count || prev.accent !== opts.accent) build(); else render();
  }
  update(o);
  if (typeof ResizeObserver !== "undefined") { ro = new ResizeObserver(measure); ro.observe(stage); }
  else { window.addEventListener("resize", measure); fixed.push(function () { window.removeEventListener("resize", measure); }); }
  function destroy() { clear(); stop(); if (ro) ro.disconnect(); ro = null; fixed.forEach(function (f) { f(); }); fixed = []; }
  return { update: update, destroy: destroy };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"accent":"#e6ff55","count":6,"tilt":28,"depth":140,"parallax":1});

How it works

Each card has a float distance d from the current position. The script writes d to a CSS variable on the card and sets a transform with translate3d, a negative Z that grows with distance, and a rotateY from d. Inside the card, five layers (sky, sun, three ridges) translate by d times a different factor, so near layers move faster than far ones and the scene shows depth. Dragging changes the position directly and tracks release velocity. On release a damped spring pulls the position to the nearest card, so the throw carries a little before it snaps. The requestAnimationFrame loop starts on interaction and stops when the spring settles.

Accessibility and performance

The stage is focusable and takes Left, Right, Home and End. Previous and next buttons and one dot per card give pointer-free and touch-free routes, and a polite live region announces the current photo. Reduced motion removes the spring and inertia, so a selection jumps straight to the card. Vertical page scroll still works over the stage on touch. Add real alt text if you swap the gradients for photos.

Only transform and opacity change, on a handful of cards, and the loop sleeps when the spring is at rest. Parallax uses CSS calc on one variable per card, so there is no per-layer JavaScript. Keep photo files small if you replace the gradients; large images on five layers per card get expensive.

SectionGallery
TechniquesCarousel, Parallax and depth, CSS 3D, Pointer interaction
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
Inspired byParallax Photo Carousel by Tom Miller on Codrops. 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. Fanned card deckA looping deck of cards fanned like a hand around the one in focus, moved with buttons, arrow keys, a swipe or a click on a side card. Depth parallax cardLayered shapes shift by different amounts as the pointer moves, drifting on touch screens.

FAQ

How do I use real photos?

Replace the gradient layers inside each card with img elements, or use background images on the same layer spans. Keep the layer order so the parallax still reads as depth.

Can it loop?

This version is bounded with a gentle rubber band at the ends. To loop, wrap the distance d with modulo the card count before rendering.

Why a spring instead of a CSS transition?

A spring can take over from a drag with the release velocity, so the card carries on in the direction you threw it. A fixed-duration transition cannot do that.