Skip to content

Image Comparison Slider

A draggable before/after divider that reveals one image over another.

Freeeditorialminimal
Category
Components
Added
2026-08-22
Deps
none
Updated
2026-09-01
Preview
After
Before
↔

Props

50
No runtime dependencies
"use client";

import { useRef, useState } from "react";

export function ImageComparisonSlider({
  before,
  after,
  initial = 50,
  labelBefore = "Before",
  labelAfter = "After",
}: {
  before: string;
  after: string;
  initial?: number;
  labelBefore?: string;
  labelAfter?: string;
}) {
  const containerRef = useRef<HTMLDivElement>(null);
  const [pos, setPos] = useState(initial);

  function updateFromClientX(clientX: number) {
    const el = containerRef.current;
    if (!el) return;
    const rect = el.getBoundingClientRect();
    const pct = ((clientX - rect.left) / rect.width) * 100;
    setPos(Math.min(100, Math.max(0, pct)));
  }

  function handleKeyDown(e: React.KeyboardEvent) {
    const step = e.shiftKey ? 10 : 2;
    switch (e.key) {
      case "ArrowLeft":
      case "ArrowDown":
        e.preventDefault();
        setPos((p) => Math.max(0, p - step));
        break;
      case "ArrowRight":
      case "ArrowUp":
        e.preventDefault();
        setPos((p) => Math.min(100, p + step));
        break;
      case "Home":
        e.preventDefault();
        setPos(0);
        break;
      case "End":
        e.preventDefault();
        setPos(100);
        break;
      default:
        break;
    }
  }

  return (
    <div
      ref={containerRef}
      className="relative h-64 w-full max-w-xl select-none overflow-hidden rounded-2xl"
      onPointerDown={(e) => {
        e.currentTarget.setPointerCapture(e.pointerId);
        updateFromClientX(e.clientX);
      }}
      onPointerMove={(e) => {
        if (e.buttons !== 1) return;
        updateFromClientX(e.clientX);
      }}
    >
      <img src={after} alt={labelAfter} className="absolute inset-0 h-full w-full object-cover" draggable={false} />
      <span className="absolute bottom-3 left-3 rounded-full bg-black/60 px-2 py-1 text-xs font-semibold text-white">
        {labelAfter}
      </span>

      <div className="absolute inset-0 overflow-hidden" style={{ width: `${pos}%` }}>
        <img
          src={before}
          alt={labelBefore}
          className="absolute inset-0 h-64 w-full max-w-xl object-cover"
          draggable={false}
        />
        <span className="absolute bottom-3 left-3 rounded-full bg-black/60 px-2 py-1 text-xs font-semibold text-white">
          {labelBefore}
        </span>
      </div>

      <div
        role="slider"
        tabIndex={0}
        aria-valuemin={0}
        aria-valuemax={100}
        aria-valuenow={Math.round(pos)}
        aria-label="Comparison position"
        onKeyDown={handleKeyDown}
        className="group absolute inset-y-0 flex w-8 -translate-x-1/2 cursor-ew-resize items-center justify-center outline-none"
        style={{ left: `${pos}%` }}
      >
        <div className="absolute inset-y-0 w-0.5 bg-white" />
        <div className="relative flex h-8 w-8 items-center justify-center rounded-full bg-white text-xs font-bold shadow-lg group-focus-visible:ring-2 group-focus-visible:ring-red-500">
          ↔
        </div>
      </div>
    </div>
  );
}
Notes

About this component

A side-by-side before/after only lets you compare two crops of attention at once; a draggable divider over one shared frame lets the eye compare the exact same pixels, which is why this pattern shows up constantly in photo-editing and redesign case studies. It's a from-scratch Pointer Events implementation, not a wrapped library — reach for it over something like react-compare-slider when you want zero dependencies and full control of the handle's look; reach for the library instead if you need pinch-zoom or a vertical-orientation variant, neither of which this component does. The non-obvious detail is setPointerCapture(e.pointerId), called on pointerdown before the first position update: it keeps the drag glued to the divider through a fast flick even if the cursor briefly outruns the pointermove events, and it's also why there's no separate pointerup handler — dragging simply stops being read the next time e.buttons isn't 1 during a move. The container listens for the drag everywhere inside it, not just on the handle, so a single click anywhere jumps the divider there immediately. The divider handle also carries role="slider" with aria-valuemin/max/now and responds to Arrow keys (2% per press, 10% with Shift) plus Home/End (jump to either end), so the comparison is fully reachable from the keyboard, not just pointer-drag.

Curator’s note

setPointerCapture on pointer down is what keeps the drag glued to the handle even during a fast flick — without it, a quick drag can outrun the events and the slider stops tracking mid-gesture.

Related

Pairs well with

Matched on shared tags and category — the entries most likely to be used alongside this one.

A custom-styled slider with a drag handle and a value tooltip while dragging.

sliderrangedrag
Pro

A live estimator with real marginal-tier overage pricing and an annual discount toggle.

pricingcalculatorslider
Featured
New

A media scrubber with buffered range, hover timestamp and pointer-capture dragging.

mediasliderscrub