Image Comparison Slider
A draggable before/after divider that reveals one image over another.
- Category
- Components
- Added
- 2026-08-22
- Deps
- none
- Updated
- 2026-09-01
Props
"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>
);
}
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.
Pairs well with
Matched on shared tags and category — the entries most likely to be used alongside this one.
Draggable Range Slider
componentsA custom-styled slider with a drag handle and a value tooltip while dragging.
Usage Pricing Calculator
componentsA live estimator with real marginal-tier overage pricing and an annual discount toggle.
Scrub Timeline Player
componentsA media scrubber with buffered range, hover timestamp and pointer-capture dragging.