Image Comparison Slider | Component Deep Dive #36: Image Comparison Slider — Before/After Without Canvas
Drag the center line, left shows before, right shows after. The core isn't Canvas — it's clip-path.
The image comparison slider (Before/After Slider) is common in photography, design, renovation, and medical imaging: showing the difference between "before" and "after" states. Users drag a divider line to control the visible area of each image in real time.
Core Principle
Two images are absolutely positioned and stacked. The bottom layer is the complete "after" image. The top layer is the "before" image, clipped with clip-path to show only the left side of the divider. Dragging the handle changes the divider position and updates the clip value.
<div class="comparison-slider" id="slider">
<img class="after" src="after.jpg" alt="After" />
<img class="before" src="before.jpg" alt="Before" />
<div class="handle">
<div class="handle-line"></div>
<div class="handle-circle">
<svg width="24" height="24"><path d="M8 6L4 12L8 18M16 6L20 12L16 18" stroke="white" stroke-width="2" fill="none"/></svg>
</div>
</div>
</div>
.comparison-slider {
position: relative;
overflow: hidden;
width: 100%;
aspect-ratio: 16/9;
cursor: ew-resize;
user-select: none;
}
.comparison-slider img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
pointer-events: none;
}
.before {
clip-path: inset(0 50% 0 0);
z-index: 2;
}
.after {
z-index: 1;
}
.handle {
position: absolute;
top: 0;
bottom: 0;
left: 50%;
z-index: 3;
transform: translateX(-50%);
pointer-events: none;
}
clip-path: inset(0 50% 0 0) means: clip 50% from the right, showing only the left 50%. This single CSS property replaces Canvas drawing, pixel manipulation, and other complex logic.
Drag Interaction
Supports mouse, touch, and keyboard:
class ComparisonSlider {
constructor(container) {
this.container = container;
this.beforeImg = container.querySelector('.before');
this.handle = container.querySelector('.handle');
this.pos = 50;
this.bindEvents();
this.update(50);
}
bindEvents() {
this.container.addEventListener('pointerdown', (e) => this.onPointerDown(e));
window.addEventListener('pointermove', (e) => this.onPointerMove(e));
window.addEventListener('pointerup', () => this.onPointerUp());
this.container.tabIndex = 0;
this.container.addEventListener('keydown', (e) => this.onKeydown(e));
}
updateFromEvent(e) {
const rect = this.container.getBoundingClientRect();
const x = e.clientX - rect.left;
const percent = (x / rect.width) * 100;
this.update(Math.max(0, Math.min(100, percent)));
}
update(percent) {
this.pos = percent;
this.beforeImg.style.clipPath = `inset(0 ${100 - percent}% 0 0)`;
this.handle.style.left = `${percent}%`;
}
onKeydown(e) {
if (e.key === 'ArrowLeft') {
e.preventDefault();
this.update(Math.max(0, this.pos - 2));
}
if (e.key === 'ArrowRight') {
e.preventDefault();
this.update(Math.min(100, this.pos + 2));
}
}
}
Using Pointer Events instead of separate mouse and touch listeners — pointerdown/move/up automatically covers mouse, touch, and stylus.
Critical Details
1. getBoundingClientRect() not offsetWidth: When calculating the drag percentage, use getBoundingClientRect().width because offsetWidth returns integer pixels while getBoundingClientRect() returns floats — on high-DPI screens, integer pixel rounding causes the percentage to stop at 99.7% instead of 100% at the edge.
2. pointer-events: none on images: Both <img> elements must set pointer-events: none, otherwise mouse events hit the images instead of the container. If images have clickable annotations or hotspots, this needs adjustment.
3. user-select: none: Prevent text selection during drag. The container and all children need this.
4. Identical image dimensions: Both images must have the same aspect ratio. If source images differ in size, use object-fit: cover to crop uniformly.
Touch Optimization
On mobile, there's an additional problem: slider dragging conflicts with page scrolling. Vertical swipes should scroll the page; horizontal swipes should drag the slider.
The solution is directional locking:
onPointerDown(e) {
this.startX = e.clientX;
this.startY = e.clientY;
this.dragging = false;
this.mightDrag = true;
}
onPointerMove(e) {
if (!this.mightDrag) return;
const dx = Math.abs(e.clientX - this.startX);
const dy = Math.abs(e.clientY - this.startY);
if (!this.dragging && dx > 10 && dx > dy) {
this.dragging = true;
this.container.setPointerCapture(e.pointerId);
}
if (this.dragging) {
e.preventDefault();
this.updateFromEvent(e);
}
}
setPointerCapture ensures subsequent pointermove and pointerup events are sent to this element even if the finger moves outside the slider.
Labels and Accessibility
Add "Before" and "After" labels on each image:
<div class="label label-before">Before</div>
<div class="label label-after">After</div>
For accessibility, the container needs role="slider", aria-valuenow, aria-valuemin="0", aria-valuemax="100", with aria-valuenow updated as the user drags.
Performance
clip-path updates are extremely fast — the browser only recalculates the clipping region without redrawing image pixels. On most devices, drag frame rate stays at 60fps. No requestAnimationFrame throttling is needed because pointermove events are already rate-limited to the screen refresh rate.
The only performance concern is large image loading: two high-resolution images may be several MB each. Use loading="lazy" and appropriately sized thumbnails, or <picture> elements for responsive images.
Top comments (0)