Dev.to WebDev 🛠 Dev 👁 0 📖 3 min read

A before/after image slider in one CSS property: clip-path, pointer-to-percent, and one setPos

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

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

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.