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 useobjectFit: "fill"because the content layer already has the correct aspect ratio fromsetNaturalSize. This avoids double-fitting. setNaturalSizeis only called on the Before image — the After image is assumed to share the same dimensions. If they differ, callsetNaturalSizefrom whichever loads first.displayZoomPctdoubles as a “reset” button label — clicking it clears pan/zoom viaresetView.