Copied!
Gallery

Horizontal scroll gallery with parallax

A row of gradient cards that moves sideways when you scroll the wheel, drag, or use the arrow keys, with parallax artwork inside each card and a progress bar.

← Library

Element · Vanilla JS + CSSUpdated November 6, 2026

ScrollElementVanilla 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 "Horizontal scroll gallery with parallax" for my site using the Enrichr Library element at https://enrichr.dev/elements/horizontal-scroll-gallery.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/horizontal-scroll-gallery.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
- speed: 1.2
- parallax: 28
- smooth: 0.12
- count: 7

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/horizontal-scroll-gallery.json.

Code

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

HTML

<div class="ef-hsg">
  <div class="ef-hsg__wrap">
    <div class="ef-hsg__vp" tabindex="0" role="region" aria-roledescription="gallery" aria-label="Horizontal gallery. Scroll, drag, or use the arrow keys to move sideways."><div class="ef-hsg__track"></div></div>
    <div class="ef-hsg__bar" role="progressbar" aria-label="Gallery progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><span class="ef-hsg__fill"></span></div>
  </div>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-hsg { width: 100%; }
.ef-hsg__wrap { box-sizing: border-box; position: relative; width: 100%; padding: 1.2rem 0 1.4rem; border-radius: 1rem; overflow: hidden; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; color: #fbf8f1; }
.ef-hsg__vp { position: relative; height: 17rem; overflow: hidden; cursor: grab; touch-action: pan-y; outline: none; user-select: none; -webkit-user-select: none; border-radius: 0.6rem; }
.ef-hsg__vp.is-drag { cursor: grabbing; }
.ef-hsg__vp:focus-visible { box-shadow: 0 0 0 2px var(--ef-acc, #e6ff55); }
.ef-hsg__track { display: flex; align-items: stretch; gap: 1rem; height: 100%; padding: 0 1.2rem; width: max-content; will-change: transform; }
.ef-hsg__card { position: relative; flex: 0 0 auto; width: 18rem; max-width: 80vw; height: 100%; border-radius: 0.9rem; overflow: hidden; box-shadow: 0 10px 18px -10px rgba(0, 0, 0, 0.6); }
.ef-hsg__art { position: absolute; top: 0; bottom: 0; left: -20%; width: 140%; will-change: transform; transform: translate3d(var(--px, 0px), 0, 0); }
.ef-hsg__card::after { content: ""; position: absolute; inset: 0; pointer-events: none; background: linear-gradient(180deg, rgba(0, 0, 0, 0.1), transparent 35%, transparent 50%, rgba(0, 0, 0, 0.62)); }
.ef-hsg__n { position: absolute; z-index: 1; top: 0.8rem; left: 0.95rem; font-size: 0.68rem; font-weight: 700; letter-spacing: 0.14em; opacity: 0.85; }
.ef-hsg__txt { position: absolute; z-index: 1; left: 0.95rem; right: 0.95rem; bottom: 0.9rem; pointer-events: none; }
.ef-hsg__t { display: block; font-size: 1.2rem; font-weight: 800; letter-spacing: -0.01em; line-height: 1.1; text-shadow: 0 2px 10px rgba(0, 0, 0, 0.35); }
.ef-hsg__s { display: block; margin-top: 0.25rem; font-size: 0.72rem; opacity: 0.8; letter-spacing: 0.04em; }
.ef-hsg__bar { position: relative; height: 3px; margin: 1rem 1.2rem 0; border-radius: 2px; background: rgba(251, 248, 241, 0.18); overflow: hidden; }
.ef-hsg__fill { position: absolute; inset: 0; border-radius: inherit; background: var(--ef-acc, #e6ff55); transform-origin: 0 50%; transform: scaleX(var(--p, 0)); }
@media (prefers-reduced-motion: reduce) { .ef-hsg__art { transform: none; } }

JavaScript

// Enrichr Library · https://enrichr.dev
// Horizontal scroll gallery: vertical wheel, drag and keys inside the element are converted into sideways movement of a row of gradient cards.
// Each card's artwork slides at a different rate than the card (parallax) and a progress bar tracks the position. Movement eases toward a target
// in one rAF loop that stops when settled. At either end the wheel is released so the page can keep scrolling. Reduced motion jumps without easing or parallax.
// mount(root, { background, accent, speed (0.3-3), parallax (0-60), smooth (0.04-0.4), count (3-10) })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var wrap = root.querySelector(".ef-hsg__wrap");
  var vp = root.querySelector(".ef-hsg__vp");
  var track = root.querySelector(".ef-hsg__track");
  var bar = root.querySelector(".ef-hsg__bar");
  var opts = {}, cards = [], arts = [], metrics = [], raf = 0, cur = 0, tgt = 0, max = 0, vw = 0, drag = null, ro = null, dead = false;
  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"], ["Slate", "Ground work"], ["Bloom", "Late season"]];

  // Enrichr puzzle-piece artwork, inline so the code stays portable. piece(i, accent) returns an aria-hidden SVG string, a different piece per card.
  var PIECES = [["1513.7 1453.9 322.3 322.3", "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"],
    ["1423.36 860.8 503.07 322.3", "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"],
    ["1513.7 166.86 322.3 503.06", "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"]];
  var PLACE = [[52, -16, 80, 14], [-24, 34, 96, -22], [46, 26, 74, 32], [-8, -20, 84, -12], [58, 38, 92, 24], [4, 18, 78, -32]]; // left %, top %, width %, rotate deg
  var PCOL = [["#2f7d5b", 0.95], ["#0c1e18", 0.8], ["ACC", 0.9], ["#c6f26b", 0.85], ["#0a6b46", 0.95], ["#fbf8f1", 0.85]];
  function piece(i, accent, sx, ox) {
    var p = PIECES[i % 3], q = PLACE[i % 6], c = PCOL[i % 6], k = sx || 1, o = ox || 0;
    return '<span class="ef-hsg__pc" aria-hidden="true" style="position:absolute;left:' + (o + q[0] * k).toFixed(1) + '%;top:' + q[1] + '%;width:' + (q[2] * k).toFixed(1) + '%;transform:rotate(' + q[3] + 'deg);color:' + (c[0] === "ACC" ? accent : c[0]) + ';opacity:' + c[1] + ';pointer-events:none"><svg viewBox="' + p[0] + '" aria-hidden="true" focusable="false" style="display:block;width:100%;height:auto"><path fill="currentColor" d="' + p[1] + '"/></svg></span>';
  }
  function art(i, a) {
    var c = "#fbf8f1", d = "#0c1e18", p = i % 6;
    if (p === 0) return "radial-gradient(circle at 60% 32%, " + a + " 0 14%, transparent 15%), linear-gradient(160deg, #005236, " + d + ")";
    if (p === 1) return "conic-gradient(from 210deg at 45% 65%, #005236, " + a + ", " + c + ", #005236)";
    if (p === 2) return "repeating-linear-gradient(120deg, #005236 0 14px, " + d + " 14px 28px)";
    if (p === 3) return "radial-gradient(circle at 36% 38%, " + a + " 0 15%, transparent 16%), radial-gradient(circle at 66% 62%, " + c + " 0 15%, transparent 16%), linear-gradient(200deg, #0a6b46, " + d + ")";
    if (p === 4) return "radial-gradient(ellipse 28% 22% at 50% 44%, " + c + " 0 99%, transparent 100%), linear-gradient(190deg, #005236, " + a + ")";
    return "radial-gradient(circle, " + a + " 0 2.5px, transparent 3px) 0 0 / 18px 18px, linear-gradient(145deg, #005236, " + d + ")";
  }
  function clamp(v) { return Math.max(0, Math.min(max, v)); }
  function measure() {
    vw = vp.clientWidth;
    max = Math.max(0, track.scrollWidth - vw);
    metrics = cards.map(function (c) { return { l: c.offsetLeft, w: c.offsetWidth }; });
    tgt = clamp(tgt); cur = clamp(cur);
    render();
  }
  function render() {
    track.style.transform = "translate3d(" + (-cur).toFixed(2) + "px,0,0)";
    var p = max > 0 ? cur / max : 0;
    wrap.style.setProperty("--p", p.toFixed(4));
    bar.style.setProperty("--p", p.toFixed(4));
    bar.setAttribute("aria-valuenow", String(Math.round(p * 100)));
    if (reduce) return;
    for (var i = 0; i < arts.length; i++) {
      var m = metrics[i]; if (!m) continue;
      var d = (m.l + m.w / 2 - cur - vw / 2) / (vw || 1); // about -1..1 across the viewport
      arts[i].style.setProperty("--px", (d * m.w * opts.parallax / 100).toFixed(1) + "px");
    }
  }
  function tick() {
    raf = 0;
    if (dead) return;
    var diff = tgt - cur;
    if (reduce || Math.abs(diff) < 0.3) { cur = tgt; render(); return; }
    cur += diff * opts.smooth;
    render();
    raf = requestAnimationFrame(tick);
  }
  function go(v) { tgt = clamp(v); if (!raf && !dead) raf = requestAnimationFrame(tick); }

  function onWheel(e) {
    var d = Math.abs(e.deltaY) >= Math.abs(e.deltaX) ? e.deltaY : e.deltaX;
    if (e.deltaMode === 1) d *= 32;
    if (max <= 0 || d === 0 || (d < 0 && tgt <= 0) || (d > 0 && tgt >= max)) return; // let the page scroll at the ends
    e.preventDefault();
    go(tgt + d * opts.speed);
  }
  function onDown(e) {
    if (e.pointerType === "mouse" && e.button !== 0) return;
    drag = { x: e.clientX, y: e.clientY, t: tgt, id: e.pointerId };
    vp.classList.add("is-drag");
    try { vp.setPointerCapture(e.pointerId); } catch (_) {}
  }
  function onMove(e) {
    if (!drag || e.pointerId !== drag.id) return;
    var dx = e.clientX - drag.x, dy = e.clientY - drag.y;
    go(drag.t - (Math.abs(dx) >= Math.abs(dy) ? dx : dy));
  }
  function onUp() {
    if (!drag) return;
    try { vp.releasePointerCapture(drag.id); } catch (_) {}
    drag = null;
    vp.classList.remove("is-drag");
  }
  function onKey(e) {
    var step = Math.max(80, vw * 0.5), k = e.key, v = null;
    if (k === "ArrowRight" || k === "ArrowDown") v = tgt + step;
    else if (k === "ArrowLeft" || k === "ArrowUp") v = tgt - step;
    else if (k === "PageDown" || k === " ") v = tgt + vw * 0.9;
    else if (k === "PageUp") v = tgt - vw * 0.9;
    else if (k === "Home") v = 0;
    else if (k === "End") v = max;
    if (v === null) return;
    if (max <= 0 || (v < tgt && tgt <= 0) || (v > tgt && tgt >= max)) return; // at the end: let the page handle the key
    e.preventDefault();
    go(v);
  }
  vp.addEventListener("wheel", onWheel, { passive: false });
  vp.addEventListener("pointerdown", onDown);
  vp.addEventListener("pointermove", onMove);
  vp.addEventListener("pointerup", onUp);
  vp.addEventListener("pointercancel", onUp);
  vp.addEventListener("keydown", onKey);
  if (window.ResizeObserver) { ro = new ResizeObserver(measure); ro.observe(vp); } else window.addEventListener("resize", measure);

  function build() {
    track.textContent = ""; cards = []; arts = [];
    for (var i = 0; i < opts.count; i++) {
      var c = document.createElement("div"); c.className = "ef-hsg__card";
      var a = document.createElement("span"); a.className = "ef-hsg__art"; a.style.background = art(i, opts.accent);
      a.insertAdjacentHTML("beforeend", piece(i, opts.accent, 0.714, 14.3)); // art is 140% wide: map to the card window
      var n = document.createElement("span"); n.className = "ef-hsg__n"; n.textContent = String(i + 1).padStart(2, "0");
      var tx = document.createElement("span"); tx.className = "ef-hsg__txt";
      var t = document.createElement("span"); t.className = "ef-hsg__t"; t.textContent = DATA[i % DATA.length][0];
      var s = document.createElement("span"); s.className = "ef-hsg__s"; s.textContent = DATA[i % DATA.length][1];
      tx.appendChild(t); tx.appendChild(s);
      c.appendChild(a); c.appendChild(n); c.appendChild(tx);
      track.appendChild(c); cards.push(c); arts.push(a);
    }
    measure();
  }
  function update(next) {
    var prev = opts;
    opts = Object.assign({ background: "#0c1e18", accent: "#e6ff55", speed: 1.2, parallax: 28, smooth: 0.12, count: 7 }, opts, next || {});
    opts.count = Math.max(3, Math.min(10, Math.round(opts.count)));
    wrap.style.background = opts.background;
    wrap.style.setProperty("--ef-acc", opts.accent);
    if (prev.count !== opts.count || prev.accent !== opts.accent) build(); else render();
  }
  function destroy() {
    dead = true;
    if (raf) cancelAnimationFrame(raf);
    raf = 0;
    vp.removeEventListener("wheel", onWheel);
    vp.removeEventListener("pointerdown", onDown);
    vp.removeEventListener("pointermove", onMove);
    vp.removeEventListener("pointerup", onUp);
    vp.removeEventListener("pointercancel", onUp);
    vp.removeEventListener("keydown", onKey);
    if (ro) ro.disconnect(); else window.removeEventListener("resize", measure);
  }
  update(o);
  return { update: update, destroy: destroy };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"accent":"#e6ff55","speed":1.2,"parallax":28,"smooth":0.12,"count":7});

How it works

The gallery is a fixed-height viewport with a wide flex track inside it. A wheel listener on the viewport adds the vertical delta to a target position, drag adds the pointer movement, and keys add fixed steps. A single requestAnimationFrame loop eases the current position toward the target and writes one translate to the track, so every card moves together. For the parallax, each card's artwork layer is wider than the card, and its offset is set from how far the card centre is from the viewport centre, so the artwork slides at a different rate than the card. The progress bar is a scaleX driven by position divided by the maximum. The loop stops as soon as the position settles, and when the gallery is already at one end the wheel event is left alone so the page keeps scrolling.

Accessibility and performance

The viewport is a focusable region with a label, and responds to arrow keys, Page Up and Page Down, Home, End and Space, so it works without a mouse or wheel. The progress bar is exposed as a progressbar with a live value. The wheel and keys are only captured while the gallery can still move in that direction, which avoids trapping page scroll. With reduced motion the position jumps to the target without easing and the parallax is switched off.

Only transforms are animated: one translate on the track and one on each artwork layer, and the progress bar uses scaleX. Card positions are measured on resize rather than every frame. The loop runs only while the position is moving and is cancelled on destroy. Keep the card count modest if each card holds a large image.

SectionGallery
TechniquesScroll-driven, Parallax and depth, Carousel
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
Inspired byHorizontal scrolling gallery by Codrops 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.

Parallax photo carouselA draggable 3D carousel of gradient photo cards with parallax inner layers, inertia and spring snap, plus arrow keys, buttons and dots. Parallax pattern sectionsFull-height sections whose patterned backgrounds drift slower than the page, so each headline floats over a deeper layer. Layered parallax sceneA seeded landscape of hill ridges, a sun and a headline tucked behind the front hill, each moving at its own rate as you scroll.

FAQ

Why not use native horizontal scrolling?

Native sideways scrolling ignores the vertical wheel on most mice. Converting the wheel delta lets people scroll the way they already do, while the cards stay on one smooth transform.

Will it trap the page scroll?

No. When the gallery reaches the first or last card the wheel event is no longer cancelled, so the page continues to scroll.

Can I use photos instead of gradients?

Yes. Set an image as the background of the artwork layer. Keep it wider than the card so the parallax shift never shows an edge.