Before/After Slider — Image Comparison Snippet
Before/After Image Comparison Slider · Media · Plain HTML, CSS & JS · Live preview
Copy & paste
Source Code
<div class="demo">
<div class="compare" id="compare">
<div class="pane pane-before">
<div class="swatch swatch-before">Before</div>
<div class="tag">Original</div>
</div>
<div class="pane pane-after" id="afterPane">
<div class="swatch swatch-after">After</div>
<div class="tag tag-after">Enhanced</div>
</div>
<div class="handle" id="handle" role="slider" tabindex="0" aria-label="Comparison position" aria-valuemin="0" aria-valuemax="100" aria-valuenow="50">
<div class="handle-line"></div>
<div class="handle-grip">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="15 18 9 12 15 6"/></svg>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="9 18 15 12 9 6"/></svg>
</div>
</div>
</div>
<p class="hint">Drag the handle, or use the arrow keys after focusing it</p>
</div>* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #f8f9fa; display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 24px; }
.demo { width: 100%; max-width: 420px; }
.compare { position: relative; width: 100%; aspect-ratio: 16/10; border-radius: 14px; overflow: hidden; user-select: none; box-shadow: 0 10px 30px rgba(0,0,0,0.12); cursor: ew-resize; }
.pane { position: absolute; inset: 0; }
.swatch { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 15px; font-weight: 700; color: rgba(255,255,255,0.55); letter-spacing: 0.04em; }
.swatch-before { background: linear-gradient(135deg, #94a3b8, #475569); }
.swatch-after { background: linear-gradient(135deg, #38bdf8, #6366f1); }
.tag { position: absolute; top: 12px; left: 12px; background: rgba(15,23,42,0.55); color: #fff; font-size: 11px; font-weight: 700; padding: 4px 10px; border-radius: 999px; letter-spacing: 0.03em; pointer-events: none; }
.tag-after { left: auto; right: 12px; }
.pane-after { clip-path: inset(0 0 0 50%); }
.handle { position: absolute; top: 0; bottom: 0; left: 50%; width: 0; transform: translateX(-50%); display: flex; align-items: center; justify-content: center; touch-action: none; }
.handle-line { position: absolute; top: 0; bottom: 0; width: 3px; background: #fff; box-shadow: 0 0 0 1px rgba(0,0,0,0.08); }
.handle-grip { position: relative; z-index: 2; width: 36px; height: 36px; border-radius: 50%; background: #fff; display: flex; align-items: center; justify-content: center; gap: 1px; color: #374151; box-shadow: 0 3px 10px rgba(0,0,0,0.25); }
.handle:focus-visible .handle-grip { outline: 2px solid #2563eb; outline-offset: 2px; }
.hint { margin-top: 10px; font-size: 12.5px; color: #9ca3af; text-align: center; }var compare = document.getElementById('compare');
var afterPane = document.getElementById('afterPane');
var handle = document.getElementById('handle');
var dragging = false;
function setPosition(pct) {
pct = Math.max(0, Math.min(100, pct));
afterPane.style.clipPath = 'inset(0 0 0 ' + pct + '%)';
handle.style.left = pct + '%';
handle.setAttribute('aria-valuenow', Math.round(pct));
}
function positionFromEvent(clientX) {
var rect = compare.getBoundingClientRect();
var pct = ((clientX - rect.left) / rect.width) * 100;
setPosition(pct);
}
compare.addEventListener('pointerdown', function(e) {
dragging = true;
handle.setPointerCapture(e.pointerId);
positionFromEvent(e.clientX);
});
compare.addEventListener('pointermove', function(e) {
if (!dragging) return;
positionFromEvent(e.clientX);
});
window.addEventListener('pointerup', function() { dragging = false; });
handle.addEventListener('keydown', function(e) {
var current = parseFloat(handle.getAttribute('aria-valuenow'));
if (e.key === 'ArrowLeft') { setPosition(current - 5); e.preventDefault(); }
else if (e.key === 'ArrowRight') { setPosition(current + 5); e.preventDefault(); }
else if (e.key === 'Home') { setPosition(0); e.preventDefault(); }
else if (e.key === 'End') { setPosition(100); e.preventDefault(); }
});
setPosition(50);Extend with AI
Take This Snippet Further With AI
The AI prompt for this one is for Patreon supporters ($3 a month), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.
What's included
Features
About this UI Snippet
Before/After Image Comparison Slider — clip-path Reveal with Pointer Capture

A before/after slider stacks two images and lets a visitor drag a divider to reveal how much of each is showing — the standard pattern for photo editing demos, before/after room renovations, and product comparison shots. This snippet builds the reveal with a single CSS property, clip-path, rather than the older technique of resizing an overflow: hidden container.
The clip-path reveal
Both images are stacked with position: absolute; inset: 0, fully overlapping. The top ("after") image has clip-path: inset(0 0 0 50%) applied, which clips away everything to the left of the 50% mark, letting the bottom ("before") image show through in that region. Dragging the handle simply rewrites the left inset percentage on every move — inset(0 0 0 ${pct}%). This is cheaper for the browser than the historical technique of width-clipping an inner wrapper, because clip-path doesn't affect layout at all; it only changes what's painted, so there's no reflow on every drag frame.
Pointer Events with capture
A single set of pointerdown/pointermove/pointerup handlers covers mouse, touch, and pen with no separate touch-event code path. handle.setPointerCapture(e.pointerId) on pointerdown is the detail that keeps the drag working smoothly on a touchscreen — without it, a finger that drifts slightly off the handle element mid-drag can lose the pointermove events entirely, since by default they're only delivered to whatever element is directly under the pointer.
Position math
positionFromEvent() reads compare.getBoundingClientRect() on every move and computes ((clientX - rect.left) / rect.width) * 100 to convert a raw pixel coordinate into a percentage. Recomputing the bounding rect on every event (rather than caching it once at drag-start) means the slider stays correct even if the container's size changes mid-drag, for example from a responsive layout shift or an orientation change on mobile.
Keyboard accessibility
The handle carries role="slider", tabindex="0", and aria-valuemin/aria-valuemax/aria-valuenow — the standard ARIA slider pattern. ArrowLeft/ArrowRight nudge the position by 5%, and Home/End jump to the extremes. This means the entire comparison is fully operable without a pointer at all, which a pure drag-only implementation would miss completely; screen readers announce the current percentage from aria-valuenow, which is kept in sync with the visual position on every update.
Why the grip has two chevrons
The circular handle grip contains two small chevrons pointing left and right rather than a single icon, which visually communicates "this drags both directions" at a glance — a subtle affordance cue that a plain circle or single-arrow icon doesn't convey.
Swapping in real images
The demo uses two CSS gradients as stand-ins so the snippet has no external image dependency. To use real photos, replace .swatch-before/.swatch-after's background with background-image: url(...); background-size: cover; background-position: center on the two .pane elements, and make sure both images share the exact same dimensions and framing — a comparison slider only reads correctly when the two images are pixel-aligned to each other.
Performance note
Because the reveal is driven by clip-path and the handle position by left (a cheap property for a thin absolutely-positioned element with no siblings reflowing around it), the drag stays smooth even with two full-resolution photographs loaded, unlike a canvas-based cross-fade approach that would need to redraw pixels on every frame.
See also the scroll before/after snippet for a scroll-triggered (rather than drag-triggered) reveal of the same concept, and the color swatch snippet if you only need a static side-by-side rather than an interactive divider.
Step by step
How to Use
- 1Copy the compare/pane/handle markupThe .compare container holds two stacked .pane divs (before and after) and a .handle slider element — copy all three as one unit.
- 2Replace the placeholder swatches with real imagesSet background-image: url(...) with background-size: cover on both .pane elements instead of the demo gradients, using two images with identical framing and dimensions.
- 3Add the CSS and JSPaste both blocks unmodified — the clip-path math and pointer handling work against percentages, not fixed pixels, so they adapt to any container width automatically.
- 4Set an aspect ratioAdjust aspect-ratio on .compare to match your images so the container doesn't distort or crop them awkwardly on different screen widths.
- 5Test keyboard accessTab to the handle and use the arrow keys — Home/End jump to the extremes, and aria-valuenow announces the current percentage to screen readers.
Real-world uses
Common Use Cases
clip-path so no layout reflow happens during the drag.Got questions?
Frequently Asked Questions
Set background-image: url(your-photo.jpg); background-size: cover; background-position: center directly on the .pane-before and .pane-after elements in place of the .swatch gradient backgrounds, keeping both images the same dimensions and framing.
clip-path only changes what is painted, not the box's layout, so the browser can update it every frame without triggering a reflow. Resizing an overflow:hidden wrapper's width changes layout on every drag frame, which is more expensive and can visibly jank on lower-end devices.
Without pointer capture, pointermove events are only delivered to whatever element sits directly under the pointer. A fast or slightly off-target touch drag can slip off the handle mid-gesture and stop receiving events; capturing the pointer on pointerdown guarantees the handle keeps receiving moves until pointerup, regardless of where the finger drifts.
Yes — the handle uses role="slider" with aria-valuemin/max/now, is focusable via tabindex="0", and responds to ArrowLeft/ArrowRight (5% steps) and Home/End (jump to extremes), with aria-valuenow kept in sync so screen readers announce the current position.
Open the Export menu on the snippet page for a React component tracking position in useState with the same pointer-capture logic in event handlers, a React + Tailwind version, a Vue 3 SFC, or an Angular standalone component — all preserve the clip-path math and the ARIA slider attributes.