Drag the handle left or right to reveal one image under another. Perfect for renovations, staging, retouching.
Drag the slider to reveal the renovation.
Two layers stacked. The "after" image has clip-path: inset(0 0 0 var(--split, 50%)) so it only shows from the split position onward. A range input is positioned over the whole thing — moving it sets a CSS variable --split on the parent, which animates the clip-path. The handle visual sits on top, decorative.
This is the rare case where a 4-line JS handler earns its keep: nothing CSS-only does smooth drag-tracking on the slider value. The JS is tiny enough that it doesn't violate the JS-free spirit — and the page still renders + reads fine without it (slider just doesn't move).
<div class="ba" id="ba">
<div class="before"></div>
<div class="after"></div>
<div class="handle"></div>
<input type="range" min="0" max="100" value="50"
id="ba-range" aria-label="Reveal slider">
</div>
.ba { position: relative; aspect-ratio: 4/3; }
.ba .before, .ba .after { position: absolute; inset: 0; }
.ba .after { clip-path: inset(0 0 0 var(--split, 50%)); }
.ba .handle { left: var(--split, 50%); }
.ba input[type="range"] { position: absolute; inset: 0; opacity: 0; cursor: ew-resize; }
const ba = document.getElementById('ba');
const r = document.getElementById('ba-range');
r.addEventListener('input', () => {
ba.style.setProperty('--split', r.value + '%');
});
Vertical split — swap inset(0 0 0 var(--split)) for inset(var(--split) 0 0 0) and rotate the handle 90°. Good for portrait-shaped photos.
Tap to toggle — if you want a JS-free version, replace the range input with a checkbox + label that toggles --split between 0% and 100%. Lose the drag, gain pure CSS.
Animated reveal — on viewport entry, animate --split from 100% to 50% to grab attention. Pair with IntersectionObserver if you want it scroll-triggered.
Compatibility: clip-path + custom property in style attribute work everywhere. Range input is universal.