Every renovation "before/after", product-photo grade, or map-layer diff you have dragged is the same tiny component: two images stacked in one box, and a divider that reveals more of one and less of the other. The whole thing is driven by a single number — the divider position as a percent — and once you see that, building one from scratch takes an afternoon. No library, no image files.
Two layers, one clipped box
Stack both images in a position:relative stage with overflow:hidden. Each layer is absolutely positioned and fills the box, so they line up pixel-for-pixel. DOM order is z-order: the AFTER layer is painted first as the full background, the BEFORE layer sits on top and is the one you trim.
The reveal is a single clip-path
To show only the left pos% of the top image, clip away its right side. clip-path: inset(top right bottom left) takes an inset from each edge, so trimming (100 - pos)% off the right leaves exactly the left pos% visible. There is no mask and no second draw — moving the divider is literally editing one number inside one string.
// reveal the left `pos`% of the BEFORE layer
before.style.clipPath = `inset(0 ${100 - pos}% 0 0)`;
// pos = 70 -> inset(0 30% 0 0) -> left 70% BEFORE, right 30% AFTER
Pointer to percent, then clamp
A pointer event carries a viewport clientX. Subtract the stage's left edge (read live from getBoundingClientRect() so it survives any scroll or resize), divide by the width for a 0–1 fraction, times 100 for a percent. A drag can leave the frame and an arrow key can overshoot, so every value is clamped to its track at one choke-point.
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
function pctFromEvent(e){
const r = stage.getBoundingClientRect();
const frac = (e.clientX - r.left) / r.width; // 0 .. 1 across the box
return clamp(frac * 100, 0, 100); // -> percent, on the track
}
One setPos writes everything
There is a single source of truth — pos — and one function that applies it. setPos clamps, stores, then fans out to every view of that number: the clip-path (the wipe), the divider position, the handle's aria-valuenow, and the read-out. Because drag, tap and keyboard all funnel through here, the picture, the ARIA and the read-out are mathematically incapable of disagreeing.
function setPos(p, src){
pos = clamp(p, 0, 100);
before.style.clipPath = `inset(0 ${100 - pos}% 0 0)`; // the wipe
divider.style.left = pos + '%'; // line + handle
const r = Math.round(pos);
handle.setAttribute('aria-valuenow', r);
handle.setAttribute('aria-valuetext', r + '% before, ' + (100 - r) + '% after');
readout.textContent = r + '%';
}
One drag path for mouse, touch and pen
Pointer events unify all three input types behind one API. The key move is setPointerCapture on pointerdown: every later move/up routes to the stage even when the pointer slides outside it, so a fast drag never "sticks". Pair it with touch-action:none on the stage so the browser does not scroll the page mid-drag on touch.
stage.addEventListener('pointerdown', e => {
dragging = true;
stage.setPointerCapture(e.pointerId);
setPos(pctFromEvent(e), 'drag');
});
stage.addEventListener('pointermove', e => { if (dragging) setPos(pctFromEvent(e), 'drag'); });
stage.addEventListener('pointerup', () => { dragging = false; });
Real keyboard accessibility, almost free
Give the handle role="slider" with aria-valuemin/max/now and a tabindex, and it becomes a genuine slider to assistive tech. On keydown, arrows nudge one percent, Shift or PageUp/Down jump ten, Home/End snap to the edges — each just calls the same setPos. Flipping to a horizontal wipe is one boolean: read clientY against the height, trim the bottom with inset(0 0 (100-pos)% 0), move top instead of left.
Drag it, tab to the handle, and read all ten build steps live at https://dev48v.infy.uk/design/day56-image-compare-slider.html
Top comments (0)