Overview
A 1:1 pointer-driven before/after comparison. The divider tracks the finger with rubber-banding past the edges, then springs back to a clamped position on release. Keyboard arrows move the slider for accessibility.
Installation
Use the CLI to install the component automatically:
$ pnpm dlx shadcn@latest add https://ui.sanjid.in/r/image-compare.jsonInstall dependencies:
$ pnpm add motionAdd the utility function for class merging:
lib/utils.ts
tsx
1import { ClassValue, clsx } from "clsx";2import { twMerge } from "tailwind-merge";3 4export function cn(...inputs: ClassValue[]) {5 return twMerge(clsx(inputs));6}
Copy the component code into your project:
tsx
1"use client";2 3import React, { useCallback, useEffect, useId, useRef, useState } from "react";4import {5 animate,6 motion,7 useMotionValue,8 useReducedMotion,9 useTransform,10} from "motion/react";11import { cn } from "@/lib/utils";12 13export type ImageCompareProps = {14 beforeSrc: string;15 afterSrc: string;16 beforeAlt?: string;17 afterAlt?: string;18 /** 0–1 initial position */19 defaultPosition?: number;20 position?: number;21 onPositionChange?: (value: number) => void;22 className?: string;23 /** Aspect ratio utility, e.g. "16 / 9" */24 aspectRatio?: string;25};26 27function rubberband(overshoot: number, dimension: number, constant = 0.55) {28 return (29 (overshoot * dimension * constant) /30 (dimension + constant * Math.abs(overshoot))31 );32}33 34const SPRING = { type: "spring" as const, bounce: 0, duration: 0.35 };35 36export function ImageCompare({37 beforeSrc,38 afterSrc,39 beforeAlt = "Before",40 afterAlt = "After",41 defaultPosition = 0.5,42 position: positionProp,43 onPositionChange,44 className,45 aspectRatio = "16 / 10",46}: ImageCompareProps) {47 const reduce = useReducedMotion();48 const labelId = useId();49 const frameRef = useRef<HTMLDivElement>(null);50 const dragging = useRef(false);51 const isControlled = positionProp !== undefined;52 const [uncontrolled, setUncontrolled] = useState(defaultPosition);53 const value = isControlled ? positionProp : uncontrolled;54 55 const mv = useMotionValue(value);56 const clip = useTransform(mv, (v) => `inset(0 ${100 - v * 100}% 0 0)`);57 const handleLeft = useTransform(mv, (v) => `${v * 100}%`);58 59 useEffect(() => {60 mv.set(value);61 }, [value, mv]);62 63 const setValue = useCallback(64 (next: number, withSpring = false) => {65 const clamped = Math.min(1, Math.max(0, next));66 if (!isControlled) setUncontrolled(clamped);67 onPositionChange?.(clamped);68 if (withSpring && !reduce) {69 animate(mv, clamped, SPRING);70 } else {71 mv.set(clamped);72 }73 },74 [isControlled, onPositionChange, mv, reduce],75 );76 77 const clientToValue = useCallback((clientX: number) => {78 const el = frameRef.current;79 if (!el) return 0.5;80 const rect = el.getBoundingClientRect();81 const raw = (clientX - rect.left) / rect.width;82 if (raw < 0) return -rubberband(-raw * rect.width, rect.width) / rect.width;83 if (raw > 1)84 return 1 + rubberband((raw - 1) * rect.width, rect.width) / rect.width;85 return raw;86 }, []);87 88 const onPointerDown = (e: React.PointerEvent) => {89 e.currentTarget.setPointerCapture(e.pointerId);90 dragging.current = true;91 const v = clientToValue(e.clientX);92 setValue(Math.min(1, Math.max(0, v)));93 };94 95 const onPointerMove = (e: React.PointerEvent) => {96 if (!dragging.current) return;97 const raw = clientToValue(e.clientX);98 // Live rubber-band visually via mv, commit clamped on release99 if (raw < 0 || raw > 1) {100 mv.set(raw);101 } else {102 setValue(raw);103 }104 };105 106 const onPointerUp = (e: React.PointerEvent) => {107 if (!dragging.current) return;108 dragging.current = false;109 try {110 e.currentTarget.releasePointerCapture(e.pointerId);111 } catch {112 /* noop */113 }114 const clamped = Math.min(1, Math.max(0, mv.get()));115 setValue(clamped, true);116 };117 118 const pct = Math.round(value * 100);119 120 return (121 <div122 ref={frameRef}123 className={cn(124 "relative w-full overflow-hidden rounded-lg",125 "border border-(--color-rule) bg-(--color-paper-2)",126 "touch-none select-none",127 "outline outline-1 outline-black/6 dark:outline-white/10",128 className,129 )}130 style={{ aspectRatio }}131 onPointerDown={onPointerDown}132 onPointerMove={onPointerMove}133 onPointerUp={onPointerUp}134 onPointerCancel={onPointerUp}135 role="group"136 aria-labelledby={labelId}137 >138 <span id={labelId} className="sr-only">139 Image comparison slider, {pct}% before revealed140 </span>141 142 {/* After (base) */}143 {/* eslint-disable-next-line @next/next/no-img-element */}144 <img145 src={afterSrc}146 alt={afterAlt}147 draggable={false}148 className="absolute inset-0 h-full w-full object-cover"149 />150 151 {/* Before (clipped) */}152 <motion.div153 className="absolute inset-0 will-change-[clip-path]"154 style={{ clipPath: clip }}155 >156 {/* eslint-disable-next-line @next/next/no-img-element */}157 <img158 src={beforeSrc}159 alt={beforeAlt}160 draggable={false}161 className="absolute inset-0 h-full w-full object-cover"162 />163 </motion.div>164 165 {/* Divider + handle */}166 <motion.div167 className="absolute inset-y-0 z-2 w-px bg-white/90 shadow-[0_0_0_1px_rgba(0,0,0,0.15)]"168 style={{169 left: handleLeft,170 x: "-50%",171 }}172 >173 <div174 className={cn(175 "absolute top-1/2 left-1/2 flex size-10 -translate-x-1/2 -translate-y-1/2",176 "items-center justify-center rounded-full",177 "border border-black/10 bg-(--color-paper)",178 "shadow-[0_4px_16px_rgba(0,0,0,0.18)]",179 "transition-transform duration-100 ease-[cubic-bezier(0.23,1,0.32,1)]",180 "active:scale-[0.96]",181 )}182 role="slider"183 aria-valuemin={0}184 aria-valuemax={100}185 aria-valuenow={pct}186 aria-label="Comparison position"187 tabIndex={0}188 onKeyDown={(e) => {189 const step = e.shiftKey ? 0.1 : 0.02;190 if (e.key === "ArrowLeft" || e.key === "ArrowDown") {191 e.preventDefault();192 setValue(value - step, true);193 }194 if (e.key === "ArrowRight" || e.key === "ArrowUp") {195 e.preventDefault();196 setValue(value + step, true);197 }198 if (e.key === "Home") {199 e.preventDefault();200 setValue(0, true);201 }202 if (e.key === "End") {203 e.preventDefault();204 setValue(1, true);205 }206 }}207 >208 <span className="flex gap-0.5" aria-hidden>209 <span className="h-3 w-0.5 rounded-full bg-(--color-ink-muted)" />210 <span className="h-3 w-0.5 rounded-full bg-(--color-ink-muted)" />211 </span>212 </div>213 </motion.div>214 215 <span className="pointer-events-none absolute top-3 left-3 rounded-sm bg-black/45 px-2 py-0.5 text-[10px] font-medium tracking-wide text-white uppercase backdrop-blur-sm">216 Before217 </span>218 <span className="pointer-events-none absolute top-3 right-3 rounded-sm bg-black/45 px-2 py-0.5 text-[10px] font-medium tracking-wide text-white uppercase backdrop-blur-sm">219 After220 </span>221 </div>222 );223}
Adjust import paths according to your folder structure.
Basic Usage
tsx
1import { ImageCompare } from "@/components/ui/image-compare";2 3<ImageCompare4 beforeSrc="/before.png"5 afterSrc="/after.png"6 defaultPosition={0.5}7/>
Props
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| beforeSrc | string | Yes | — | Image revealed on the left of the divider. |
| afterSrc | string | Yes | — | Base image under the clip. |
| defaultPosition | number | No | 0.5 | Initial position from 0–1. |
| position | number | No | — | Controlled position from 0–1. |
| onPositionChange | (value: number) => void | No | — | Fires as the scrubber moves (clamped). |
| aspectRatio | string | No | "16 / 10" | CSS aspect-ratio for the frame. |
Best Practices
- Track on pointer-down (not click) so scrubbing feels direct.
- Animate
clip-pathvia Motion values — keep the handle ontransform. - Rubber-band while dragging past edges; spring settle only on release.
- Expose a real
role="slider"on the handle for keyboard users.