Skip to content

Image Comparison

The canonical use case: two images of the same subject — a before/after retouch, a photo filter preview, a render comparison — with a draggable divider and synchronized pan/zoom.

import { useSplitView } from "use-split-view"
interface Props {
beforeSrc: string
afterSrc: string
height?: number
}
export function ImageCompare({ beforeSrc, afterSrc, height = 500 }: Props) {
const {
containerRef,
getPaneState,
handleProps,
setNaturalSize,
split,
displayZoomPct,
resetView,
} = useSplitView({ direction: "horizontal" })
const start = getPaneState("start")
const end = getPaneState("end")
return (
<div
ref={containerRef}
style={{
position: "relative",
width: "100%",
height,
overflow: "hidden",
touchAction: "none",
userSelect: "none",
borderRadius: 12,
background: "#0f172a",
}}
>
{/* Before */}
<div style={{ position: "absolute", inset: 0, clipPath: start.clipPath }}>
<div
style={{
width: "100%",
height: "100%",
transformOrigin: "top left",
transform: start.transform,
}}
>
<div style={start.contentStyle}>
<img
src={beforeSrc}
alt="Before"
draggable={false}
style={{ width: "100%", height: "100%", objectFit: "fill" }}
onLoad={(e) =>
setNaturalSize(e.currentTarget.naturalWidth, e.currentTarget.naturalHeight)
}
/>
</div>
</div>
</div>
{/* After */}
<div style={{ position: "absolute", inset: 0, clipPath: end.clipPath }}>
<div
style={{
width: "100%",
height: "100%",
transformOrigin: "top left",
transform: end.transform,
}}
>
<div style={end.contentStyle}>
<img
src={afterSrc}
alt="After"
draggable={false}
style={{ width: "100%", height: "100%", objectFit: "fill" }}
/>
</div>
</div>
</div>
{/* Drag handle */}
<div
{...handleProps}
style={{
position: "absolute",
top: 0,
bottom: 0,
left: `${split}%`,
width: 32,
transform: "translateX(-50%)",
cursor: "col-resize",
zIndex: 10,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<div style={{ position: "absolute", top: 0, bottom: 0, width: 2, background: "#fff", boxShadow: "0 0 8px rgba(0,0,0,0.6)" }} />
<div
style={{
width: 32,
height: 32,
borderRadius: "50%",
background: "#fff",
boxShadow: "0 2px 10px rgba(0,0,0,0.4)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 14,
fontFamily: "system-ui, sans-serif",
color: "#0f172a",
}}
>
</div>
</div>
{/* Labels */}
<div style={labelStyle("left")}>Before</div>
<div style={labelStyle("right")}>After</div>
{/* Zoom indicator / reset */}
<button
type="button"
onClick={resetView}
style={{
position: "absolute",
top: 12,
right: 12,
padding: "6px 10px",
borderRadius: 6,
border: "1px solid rgba(255,255,255,0.25)",
background: "rgba(0,0,0,0.5)",
color: "#fff",
fontFamily: "ui-monospace, monospace",
fontSize: 12,
cursor: "pointer",
zIndex: 20,
}}
>
{displayZoomPct}%
</button>
</div>
)
}
const labelStyle = (side: "left" | "right"): React.CSSProperties => ({
position: "absolute",
top: 12,
[side]: 12,
padding: "4px 10px",
borderRadius: 6,
background: "rgba(0,0,0,0.5)",
color: "#fff",
fontFamily: "system-ui, sans-serif",
fontSize: 12,
fontWeight: 600,
pointerEvents: "none",
})
<ImageCompare beforeSrc="/photos/room-before.jpg" afterSrc="/photos/room-after.jpg" />
  • Both <img> elements use objectFit: "fill" because the content layer already has the correct aspect ratio from setNaturalSize. This avoids double-fitting.
  • setNaturalSize is only called on the Before image — the After image is assumed to share the same dimensions. If they differ, call setNaturalSize from whichever loads first.
  • displayZoomPct doubles as a “reset” button label — clicking it clears pan/zoom via resetView.