9 Before After Slider UIs — Drag to Compare
A before after slider stacks two photos and splits them at a line you can move, so before and after show side by side.
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Click-to-reveal comparison slider
- 02 Drag-handle comparison slider
- 03 Touch-swipe comparison slider
- 04 Keyboard-accessible comparison slider
- 05 Vertical comparison slider
- 06 Before/After label badges
- 07 Caption-overlay comparison slider
- 08 Zoom-synced comparison slider
- 09 Multi-pair comparison carousel
These nine follow the order the component actually gets used in, not a "prettiest first" list: click once to see it move, drag it continuously, try it on a phone, check it with only a keyboard, flip the axis to vertical, add labels, add a caption, zoom in, then finally line several pairs up on one page.
01Click-to-reveal comparison slider
Click anywhere on the photo and the divider jumps straight to that x-position. It's the simplest version to try first, since there's no drag involved at all.
el.addEventListener("click", function (e) {
el.classList.remove("is-demo");
var r = el.getBoundingClientRect();
var pct = ((e.clientX - r.left) / r.width) * 100;
el.style.setProperty("--reveal", Math.max(0, Math.min(100, pct)) + "%");
});
02Drag-handle comparison slider
Drag the round handle back and forth and it follows the pointer in real time. This is the version most product pages actually ship.
grip.addEventListener("pointerdown", function (e) {
el.classList.remove("is-demo");
dragging = true;
grip.setPointerCapture(e.pointerId);
set(fromX(e.clientX));
});
grip.addEventListener("pointermove", function (e) {
if (dragging) set(fromX(e.clientX));
});
03Touch-swipe comparison slider
The whole slider is the touch target, not just a thin handle, and the grip grows to 48px so a fingertip lands on it reliably.
.ba {
touch-action: none;
cursor: grab;
}
.ba__grip {
width: $sp-12; // 48px — a realistic minimum touch target
height: $sp-12;
border-radius: $r-pill;
}
04Keyboard-accessible comparison slider
Tab onto the slider and arrow keys move it 5% at a time; Home and End jump to either end. No mouse required to compare before and after.
el.addEventListener("keydown", function (e) {
el.classList.remove("is-demo");
if (e.key === "ArrowLeft") { set(cur() - 5); e.preventDefault(); }
else if (e.key === "ArrowRight") { set(cur() + 5); e.preventDefault(); }
else if (e.key === "Home") { set(0); e.preventDefault(); }
else if (e.key === "End") { set(100); e.preventDefault(); }
});
05Vertical comparison slider
The divider moves up and down instead of left and right, so a tall vertical photo doesn't need to be rotated to compare.
.ba__before {
clip-path: inset(0 0 calc(100% - var(--reveal)) 0);
}
.ba__handle {
top: var(--reveal);
height: $sp-1;
transform: translateY(-50%);
}
06Before/after label badges
Fixed pill badges in the top corners fade in and out as the divider passes, so it's never ambiguous which side is which.
function set(pct) {
pct = Math.max(0, Math.min(100, pct));
// one property for the position, one plain 0-1 number for opacity math
el.style.setProperty("--reveal", pct + "%");
el.style.setProperty("--reveal-num", (pct / 100).toFixed(3));
}
07Caption-overlay comparison slider
A translucent gradient band sits along the bottom with a caption like "3 weeks later," spelling out in words what the photos compare.
.ba__caption {
position: absolute;
left: 0; right: 0; bottom: 0;
padding: $sp-6 $sp-4 $sp-3;
background: linear-gradient(to top, rgba(23,20,26,.78) 0%, rgba(23,20,26,0) 100%);
color: $color;
font: 700 13px/1.3 system-ui, sans-serif;
}
08Zoom-synced comparison slider
Scrolling the wheel zooms both photos together at the same scale and center point, so a close look at fine detail never drifts out of sync.
el.addEventListener("wheel", function (e) {
el.classList.remove("is-demo");
e.preventDefault();
var cur = parseFloat(getComputedStyle(el).getPropertyValue("--zoom")) || 1;
setZoom(cur + (e.deltaY < 0 ? 0.15 : -0.15));
}, { passive: false });
09Multi-pair comparison carousel
Three sliders sit side by side in a scroll-snap carousel, and IntersectionObserver enables dragging only on whichever pane is centered in view.
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (en) {
en.target.classList.toggle("is-active", en.intersectionRatio > 0.6);
});
}, { root: track, threshold: [0.6] });
panes.forEach(function (p) { io.observe(p); });
Where does it break — old browsers don't glide
All nine manage the divider through one custom property, --reveal. Registering it with @property as a <percentage> is what lets each auto-preview loop glide smoothly between its low and high point (roughly 20–30% up to 70–80%, depending on the item) — without that registration, a keyframe animation on a custom property can't be interpolated at all, so the browser jumps between values instead of tweening. The good news is that real dragging and clicking write the value straight into an inline style from JS, so a browser that doesn't support this registration still lets a person operate the slider by hand just fine. While building this set, the vertical one (item 05) looked fine at the 480×300 stage but actually overflowed vertically once checked at a 320px-wide viewport, so its card width had to shrink from 200px to 120px — a vertical slider runs out of height on a narrow screen faster than a horizontal one runs out of width. The zip's password is dv4hytn4, and every folder ships both a vanilla and a React version side by side.
Accessibility — what reduced-motion leaves behind
With prefers-reduced-motion: reduce, all nine stop their auto-preview loop (the is-demo animation) and settle at a fixed 50%. Item 04 already lets someone move the whole range with just a keyboard, so turning the animation off costs it nothing. Item 08's zoom follows the same rule and locks its scale to 1 when motion is reduced.
FAQ
Does a before/after slider need JavaScript?
Yes. The divider has to track live input from a click, drag, or key press, which means something has to keep updating --reveal. All nine use between 12 and 34 lines of plain vanilla JS to do it.
How do I swap in real photos?
Replace the background on .ba__after and .ba__before with url(path), or switch the div to an <img> with object-fit: cover. The zip's README spells out both options.
Can I mix vertical and horizontal sliders on one page?
Yes — they only differ in which axis --reveal and clip-path move along, so as long as class names don't collide, both can live on the same page.
The clip-path mechanics are documented on MDN's clip-path page, and pointer capture on MDN's Pointer events page. For a different way to lay out multiple photos, see 9 CSS Image Gallery Grid Effects, and browse more real-world UI in the UI category.