DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

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 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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 + '%';
}
Enter fullscreen mode Exit fullscreen mode

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; });
Enter fullscreen mode Exit fullscreen mode

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)