DEV Community

WDSEGA
WDSEGA

Posted on

Component Deep Dive #36: Image Comparison Slider — Before/After Without Canvas

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

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

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

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

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)