Copied!
Gallery

Image compare on scroll

A before and after slider whose divider is scrubbed by scroll position and can also be dragged or keyboard-operated.

← Library

Element · Vanilla JS + SVG + CSSUpdated November 5, 2026

ScrollElementVanilla JS + SVG + 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 "Image compare on scroll" for my site using the Enrichr Library element at https://enrichr.dev/elements/image-compare-scroll.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/image-compare-scroll.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:
- labelBefore: Before
- labelAfter: After
- scrub: 1
- manual: 0
- colorA: #005236
- colorB: #e6ff55

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-compare-scroll.json.

Code

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

HTML

<div class="ef-cmp">
  <div class="ef-cmp__view">
    <div class="ef-cmp__layer ef-cmp__before"></div>
    <div class="ef-cmp__layer ef-cmp__after"></div>
    <span class="ef-cmp__tag ef-cmp__tag--a"></span>
    <span class="ef-cmp__tag ef-cmp__tag--b"></span>
    <div class="ef-cmp__bar" role="slider" tabindex="0" aria-orientation="horizontal" aria-valuemin="0" aria-valuemax="100" aria-valuenow="50"><span class="ef-cmp__knob" aria-hidden="true"></span></div>
  </div>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-cmp { width: 100%; font-family: "Helvetica Neue", Arial, "Segoe UI", system-ui, sans-serif; }
.ef-cmp__view { position: relative; width: 100%; min-height: 16rem; height: var(--ef-cmp-h, 24rem); overflow: hidden; background: #0c1e18; touch-action: pan-y; user-select: none; -webkit-user-select: none; cursor: ew-resize; --x: 50%; }
.ef-cmp__layer { position: absolute; inset: 0; }
.ef-cmp__layer svg { display: block; width: 100%; height: 100%; }
.ef-cmp__after { clip-path: inset(0 0 0 var(--x)); }
.ef-cmp__tag { position: absolute; top: 0.8rem; z-index: 2; padding: 0.25rem 0.6rem; border-radius: 999px; font-size: 0.7rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; background: rgba(12, 30, 24, 0.78); color: #fbf8f1; pointer-events: none; }
.ef-cmp__tag--a { left: 0.8rem; }
.ef-cmp__tag--b { right: 0.8rem; background: var(--ef-cmp-acc, #e6ff55); color: #0c1e18; }
.ef-cmp__bar { position: absolute; top: 0; bottom: 0; left: var(--x); width: 44px; margin-left: -22px; z-index: 3; display: flex; align-items: center; justify-content: center; outline: none; }
.ef-cmp__bar::before { content: ""; position: absolute; top: 0; bottom: 0; left: 50%; width: 2px; margin-left: -1px; background: var(--ef-cmp-acc, #e6ff55); box-shadow: 0 0 0 1px rgba(12, 30, 24, 0.35); }
.ef-cmp__knob { position: relative; width: 38px; height: 38px; border-radius: 50%; background: var(--ef-cmp-acc, #e6ff55); box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35); }
.ef-cmp__knob::before, .ef-cmp__knob::after { content: ""; position: absolute; top: 50%; width: 0; height: 0; margin-top: -5px; border: 5px solid transparent; }
.ef-cmp__knob::before { left: 6px; border-right-color: #0c1e18; }
.ef-cmp__knob::after { right: 6px; border-left-color: #0c1e18; }
.ef-cmp__bar:focus-visible .ef-cmp__knob { box-shadow: 0 0 0 3px #0c1e18, 0 0 0 6px var(--ef-cmp-acc, #e6ff55); }

JavaScript

// Enrichr Library · https://enrichr.dev
// Image compare on scroll: a before / after divider whose position is scrubbed by how far the block has travelled up the screen.
// The divider is also a real slider: drag it, click anywhere, or use arrow keys, Home, End and Page Up / Down. Scrolling again hands control back.
// Both states are drawn as inline SVG (a rough line sketch and a finished painting of the same landscape), so there are no image files.
// mount(root, { labelBefore, labelAfter, scrub ("1" = scroll moves the divider, "0" = off), manual (0 = follow scroll, 1-100 = fixed %),
//               height (px), colorA (painting green), colorB (accent) })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var box = root.querySelector(".ef-cmp");
  var view = root.querySelector(".ef-cmp__view");
  var before = root.querySelector(".ef-cmp__before");
  var after = root.querySelector(".ef-cmp__after");
  var tagA = root.querySelector(".ef-cmp__tag--a");
  var tagB = root.querySelector(".ef-cmp__tag--b");
  var bar = root.querySelector(".ef-cmp__bar");
  var uid = "efc" + Math.random().toString(36).slice(2, 7);
  var opts = {}, raf = 0, visible = true, io = null;
  var pos = 50, target = 50, lastY = -1, held = false, drag = false, shown = -1;

  var HILL_FAR = "M0 330 C120 255 220 305 330 268 S560 238 800 300 V500 H0Z";
  var HILL_MID = "M0 388 C150 322 260 376 420 334 S680 312 800 356 V500 H0Z";
  var HILL_NEAR = "M0 448 C200 404 330 456 520 424 S720 402 800 436 V500 H0Z";
  function tree(x, y, s) { return "M" + x + " " + y + " l" + (-9 * s) + " 0 l" + (9 * s) + " " + (-34 * s) + " l" + (9 * s) + " " + (34 * s) + "Z"; }
  var TREES = tree(110, 352, 1) + tree(150, 362, 1.25) + tree(196, 366, 0.9) + tree(612, 330, 1.1) + tree(660, 326, 1.4) + tree(716, 338, 1);

  function col(v, fb) { return /^(#[0-9a-f]{3,8}|[a-z]{3,20}|rgba?([ds.,%]+))$/i.test(String(v)) ? String(v) : fb; }
  function paintedSVG() {
    return '<svg viewBox="0 0 800 500" preserveAspectRatio="xMidYMax slice" aria-hidden="true">' +
      '<defs><linearGradient id="' + uid + 's" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#bfe3cf"/><stop offset="1" stop-color="#fbf8f1"/></linearGradient>' +
      '<radialGradient id="' + uid + 'g"><stop offset="0" stop-color="' + opts.colorB + '" stop-opacity="0.9"/><stop offset="1" stop-color="' + opts.colorB + '" stop-opacity="0"/></radialGradient></defs>' +
      '<rect width="800" height="500" fill="url(#' + uid + 's)"/>' +
      '<circle cx="560" cy="268" r="150" fill="url(#' + uid + 'g)"/><circle cx="560" cy="268" r="54" fill="' + opts.colorB + '"/>' +
      '<path d="' + HILL_FAR + '" fill="#6aa98a"/><path d="' + HILL_MID + '" fill="' + opts.colorA + '"/>' +
      '<path d="' + TREES + '" fill="#0c1e18" opacity="0.85"/><path d="' + HILL_NEAR + '" fill="#0c1e18"/>' +
      '<g fill="#fbf8f1"><rect x="330" y="395" width="64" height="40"/></g><path d="M322 396 L362 366 L402 396Z" fill="' + opts.colorB + '"/><rect x="352" y="410" width="14" height="25" fill="#0c1e18"/></svg>';
  }
  function sketchSVG() {
    var st = 'fill="none" stroke="#fbf8f1" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"';
    var grid = "";
    for (var i = 1; i < 16; i++) grid += "M" + i * 50 + " 0V500";
    for (var j = 1; j < 10; j++) grid += "M0 " + j * 50 + "H800";
    return '<svg viewBox="0 0 800 500" preserveAspectRatio="xMidYMax slice" aria-hidden="true">' +
      '<rect width="800" height="500" fill="#15281f"/><path d="' + grid + '" stroke="#fbf8f1" stroke-opacity="0.07" stroke-width="1" fill="none"/>' +
      '<circle cx="560" cy="268" r="54" ' + st + ' stroke-dasharray="6 7"/>' +
      '<path d="' + HILL_FAR + '" ' + st + ' stroke-opacity="0.45"/><path d="' + HILL_MID + '" ' + st + ' stroke-opacity="0.7"/>' +
      '<path d="' + TREES + '" ' + st + ' stroke-opacity="0.7"/><path d="' + HILL_NEAR + '" ' + st + '/>' +
      '<rect x="330" y="395" width="64" height="40" ' + st + '/><path d="M322 396 L362 366 L402 396Z" ' + st + '/><rect x="352" y="410" width="14" height="25" ' + st + '/>' +
      '<path d="M40 40h20M50 30v20M740 460h20M750 450v20" ' + st + ' stroke-opacity="0.5"/></svg>';
  }
  function render() {
    // the SVG strings contain only our own constants and colour values checked by col()
    before.innerHTML = sketchSVG();
    after.innerHTML = paintedSVG();
    tagA.textContent = opts.labelBefore;
    tagB.textContent = opts.labelAfter;
    view.style.setProperty("--ef-cmp-h", opts.height + "px");
    view.style.setProperty("--ef-cmp-acc", opts.colorB);
    bar.setAttribute("aria-label", opts.labelBefore + " and " + opts.labelAfter + " comparison");
    paint(true);
  }
  function paint(force) {
    var r = Math.round(pos * 10) / 10;
    if (!force && r === shown) return;
    shown = r;
    view.style.setProperty("--x", r + "%");
    bar.setAttribute("aria-valuenow", String(Math.round(r)));
    bar.setAttribute("aria-valuetext", Math.round(r) + "% " + opts.labelBefore + ", " + (100 - Math.round(r)) + "% " + opts.labelAfter);
  }
  function scrollTarget() {
    var rc = view.getBoundingClientRect(), vh = window.innerHeight || 800;
    var c = (rc.top + rc.height / 2) / vh;
    return Math.min(100, Math.max(0, ((0.85 - c) / 0.7) * 100));
  }
  function setFromPointer(e) {
    var rc = view.getBoundingClientRect();
    held = true; target = pos = Math.min(100, Math.max(0, ((e.clientX - rc.left) / rc.width) * 100));
    paint(false);
  }
  function down(e) { drag = true; try { view.setPointerCapture(e.pointerId); } catch (x) {} setFromPointer(e); bar.focus({ preventScroll: true }); }
  function move(e) { if (drag) setFromPointer(e); }
  function up() { drag = false; }
  function key(e) {
    var step = e.shiftKey ? 10 : 2, v = null;
    if (e.key === "ArrowLeft" || e.key === "ArrowDown") v = target - step;
    else if (e.key === "ArrowRight" || e.key === "ArrowUp") v = target + step;
    else if (e.key === "PageDown") v = target - 10;
    else if (e.key === "PageUp") v = target + 10;
    else if (e.key === "Home") v = 0;
    else if (e.key === "End") v = 100;
    if (v === null) return;
    e.preventDefault();
    held = true; target = pos = Math.min(100, Math.max(0, v)); paint(false);
  }
  function tick() {
    raf = requestAnimationFrame(tick);
    if (!visible) return;
    var y = window.scrollY;
    if (lastY < 0) lastY = y;
    if (Math.abs(y - lastY) > 0.5) { lastY = y; if (opts.scrub === 1 && opts.manual === 0) held = false; }
    if (!held && !drag && opts.scrub === 1 && opts.manual === 0 && !reduce) target = scrollTarget();
    if (!drag && !held) { pos += (target - pos) * 0.16; if (Math.abs(target - pos) < 0.05) pos = target; }
    paint(false);
  }
  function update(next) {
    opts = Object.assign({ labelBefore: "Before", labelAfter: "After", scrub: "1", manual: 0, height: 384, colorA: "#005236", colorB: "#e6ff55" }, opts, next || {});
    opts.colorA = col(opts.colorA, "#005236"); opts.colorB = col(opts.colorB, "#e6ff55");
    opts.scrub = Number(opts.scrub); opts.manual = Number(opts.manual); opts.height = Number(opts.height);
    held = false;
    target = pos = opts.manual > 0 ? opts.manual : (reduce || !opts.scrub ? 50 : scrollTarget());
    render();
  }
  view.addEventListener("pointerdown", down);
  view.addEventListener("pointermove", move);
  view.addEventListener("pointerup", up);
  view.addEventListener("pointercancel", up);
  bar.addEventListener("keydown", key);
  update(o);
  if ("IntersectionObserver" in window) { io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; }); io.observe(box); }
  raf = requestAnimationFrame(tick);
  return { update: update, destroy: function () {
    cancelAnimationFrame(raf); if (io) io.disconnect();
    view.removeEventListener("pointerdown", down); view.removeEventListener("pointermove", move);
    view.removeEventListener("pointerup", up); view.removeEventListener("pointercancel", up);
    bar.removeEventListener("keydown", key);
  } };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"labelBefore":"Before","labelAfter":"After","scrub":1,"manual":0,"colorA":"#005236","colorB":"#e6ff55"});

How it works

Two layers of the same size are stacked. The top one holds the finished state and is clipped with clip-path: inset from the divider position to the right edge, so moving one CSS variable reveals more or less of it. Both states are inline SVG: a rough line sketch and a painted version of the same landscape. As the block travels up the screen, its centre position is mapped to a divider position from 0 to 100 percent and eased each frame. Dragging, clicking or using the keyboard sets the position directly and takes over; the next scroll movement hands control back to the page.

Accessibility and performance

The divider is a focusable element with role slider, a label, and a spoken value describing how much of each state is showing. Arrow keys move it by 2, Shift with an arrow by 10, Page Up and Page Down by 10, and Home and End jump to the ends. With prefers-reduced-motion the divider starts at the middle and never moves on scroll, but dragging and the keyboard still work.

One clip-path change per frame while the position is moving, with no layout work, and nothing at all when it is still. The SVG is small and is drawn once. Paused when off screen.

SectionGallery
TechniquesScroll-driven, Pointer interaction, Reveal and intro
StackVanilla JS, SVG, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
Inspired byImage comparison on scroll by GSAP 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.

Unroll image on scrollA poster that unrolls like a sheet of paper from a spiral roll as you scroll or hover. Mask shape sectionsThe same image revealed through a circle, a diamond and a band as each section scrolls into view. Clip reveal menuText rows that open a generated pattern behind the word with a clip-path transition on hover, while the word inverts along the edge.

FAQ

Can I use real photos?

Yes. Put your two images in the two layer elements in place of the SVG and keep both the same size, using object-fit: cover so they line up.

Why does scrolling take control back?

Otherwise a visitor who dragged the divider would find it stuck while they read on. Any new scroll movement returns the divider to the page, and dragging takes it back.

Does it work with touch?

Yes. It uses pointer events with capture, and the touch-action setting leaves vertical page scrolling alone while you drag sideways.