Overview
A pointer-driven before/after comparison with a frosted seam: a narrow band of backdrop blur rides the divider and blurs more the faster you drag, like motion blur behind glass. The glass handle grows and its arrows spread while you hold it. Tap anywhere and the divider glides there; past the edges it rubber-bands, then springs back. Labels blur out as their side gets covered, images sharpen in as they load, and a one-time sweep hints that it's draggable when it first scrolls into view. Arrow keys nudge (Shift for bigger steps); Home/End jump to the edges.
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 useInView,8 useMotionValue,9 useReducedMotion,10 useTransform,11 useVelocity,12 type AnimationPlaybackControls,13 type MotionValue,14} from "motion/react";15import { cn } from "@/lib/utils";16 17export type ImageCompareProps = {18 beforeSrc: string;19 afterSrc: string;20 beforeAlt?: string;21 afterAlt?: string;22 /** Chip text over each side. */23 beforeLabel?: string;24 afterLabel?: string;25 /** 0–1 initial position */26 defaultPosition?: number;27 position?: number;28 onPositionChange?: (value: number) => void;29 /** Play a one-time sweep when the frame first scrolls into view. */30 hint?: boolean;31 className?: string;32 /** CSS aspect-ratio, e.g. "16 / 9" */33 aspectRatio?: string;34};35 36function rubberband(overshoot: number, dimension: number, constant = 0.55) {37 return (38 (overshoot * dimension * constant) /39 (dimension + constant * Math.abs(overshoot))40 );41}42 43const SPRING = { type: "spring" as const, bounce: 0, duration: 0.35 };44const JUMP = { type: "spring" as const, bounce: 0, duration: 0.3 };45const clampPosition = (value: number) =>46 Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 0.5;47 48export function ImageCompare({49 beforeSrc,50 afterSrc,51 beforeAlt = "Before",52 afterAlt = "After",53 beforeLabel = "Before",54 afterLabel = "After",55 defaultPosition = 0.5,56 position: positionProp,57 onPositionChange,58 hint = true,59 className,60 aspectRatio = "16 / 10",61}: ImageCompareProps) {62 const reduce = useReducedMotion();63 const labelId = useId();64 const frameRef = useRef<HTMLDivElement>(null);65 const dragging = useRef(false);66 const interacted = useRef(false);67 const anim = useRef<AnimationPlaybackControls | null>(null);68 const isControlled = positionProp !== undefined;69 const [uncontrolled, setUncontrolled] = useState(() => clampPosition(defaultPosition));70 const value = clampPosition(isControlled ? positionProp : uncontrolled);71 const [isDragging, setIsDragging] = useState(false);72 73 /* ── Motion values ────────────────────────────────────── */74 75 const mv = useMotionValue(value);76 const clip = useTransform(mv, (v) => `inset(0 ${100 - v * 100}% 0 0)`);77 const left = useTransform(mv, (v) => `${v * 100}%`);78 79 // 0 idle → 0.5 hover → 1 dragging. Drives the seam, handle and arrows.80 const active = useMotionValue(0);81 const setActive = (to: number) => {82 if (reduce) active.set(to);83 else animate(active, to, { duration: 0.22, ease: [0.23, 1, 0.32, 1] });84 };85 86 // The seam frosts more the faster it moves, like motion blur behind glass.87 const velocity = useVelocity(mv);88 const seamBlur = useTransform(() => {89 const speed = Math.min(Math.abs(velocity.get()), 4);90 return Math.min(10, active.get() * 4 + speed * 1.6);91 });92 const seamFilter = useTransform(seamBlur, (b) => `blur(${b.toFixed(2)}px) saturate(1.4)`);93 const seamWidth = useTransform(() => 28 + active.get() * 20 + Math.min(Math.abs(velocity.get()), 4) * 10);94 const handleScale = useTransform(active, [0, 0.5, 1], [1, 1.04, 1.1]);95 const arrowSpread = useTransform(active, [0, 1], [0, 2.5]);96 const arrowSpreadNeg = useTransform(arrowSpread, (v) => -v);97 const lineGlow = useTransform(98 active,99 // Dark hairline keeps the line visible on light images; the glow on dark ones.100 (a) =>101 `0 0 0 0.5px rgba(0,0,0,0.18), 0 0 ${(6 + a * 10).toFixed(1)}px rgba(255,255,255,${(0.35 + a * 0.35).toFixed(2)})`,102 );103 104 // Each chip blurs away as its side gets covered.105 const beforeChip = useTransform(mv, [0.06, 0.2], [0, 1]);106 const afterChip = useTransform(mv, [0.8, 0.94], [1, 0]);107 const beforeChipBlur = useTransform(beforeChip, (o) => `blur(${((1 - o) * 4).toFixed(1)}px)`);108 const afterChipBlur = useTransform(afterChip, (o) => `blur(${((1 - o) * 4).toFixed(1)}px)`);109 110 useEffect(() => {111 if (!dragging.current) mv.set(value);112 // eslint-disable-next-line react-hooks/exhaustive-deps113 }, [value]);114 115 const commit = useCallback(116 (next: number) => {117 const clamped = clampPosition(next);118 if (!isControlled) setUncontrolled(clamped);119 onPositionChange?.(clamped);120 return clamped;121 },122 [isControlled, onPositionChange],123 );124 125 const springTo = (target: number, transition = SPRING) => {126 const clamped = commit(target);127 anim.current?.stop();128 if (reduce) mv.set(clamped);129 else anim.current = animate(mv, clamped, transition);130 };131 132 /* ── One-time hint sweep ──────────────────────────────── */133 134 const inView = useInView(frameRef, { once: true, amount: 0.6 });135 useEffect(() => {136 if (!inView || !hint || reduce || interacted.current) return;137 const start = mv.get();138 anim.current = animate(mv, [start, start + 0.12, start - 0.08, start], {139 duration: 1.5,140 ease: [0.45, 0, 0.2, 1],141 times: [0, 0.35, 0.7, 1],142 delay: 0.25,143 });144 // eslint-disable-next-line react-hooks/exhaustive-deps145 }, [inView]);146 147 /* ── Pointer ──────────────────────────────────────────── */148 149 const clientToValue = useCallback((clientX: number) => {150 const el = frameRef.current;151 if (!el) return 0.5;152 const rect = el.getBoundingClientRect();153 if (rect.width <= 0) return 0.5;154 const raw = (clientX - rect.left) / rect.width;155 if (raw < 0) return -rubberband(-raw * rect.width, rect.width) / rect.width;156 if (raw > 1) return 1 + rubberband((raw - 1) * rect.width, rect.width) / rect.width;157 return raw;158 }, []);159 160 const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {161 if (e.button !== 0 || !e.isPrimary) return;162 interacted.current = true;163 dragging.current = true;164 setIsDragging(true);165 setActive(1);166 try {167 e.currentTarget.setPointerCapture(e.pointerId);168 } catch {169 /* pointer already released */170 }171 // Tapping away from the divider glides it over instead of teleporting.172 springTo(clientToValue(e.clientX), JUMP);173 };174 175 const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {176 if (!dragging.current) return;177 anim.current?.stop();178 const raw = clientToValue(e.clientX);179 // Rubber-band live past the edges; commit only in-bounds values.180 mv.set(raw);181 if (raw >= 0 && raw <= 1) commit(raw);182 };183 184 const onPointerUp = () => {185 if (!dragging.current) return;186 dragging.current = false;187 setIsDragging(false);188 setActive(0);189 const current = mv.get();190 if (current < 0 || current > 1) springTo(current);191 };192 193 const onKeyDown = (e: React.KeyboardEvent) => {194 const step = e.shiftKey ? 0.1 : 0.02;195 const map: Record<string, number> = {196 ArrowLeft: value - step,197 ArrowDown: value - step,198 ArrowRight: value + step,199 ArrowUp: value + step,200 Home: 0,201 End: 1,202 };203 if (!(e.key in map)) return;204 e.preventDefault();205 interacted.current = true;206 springTo(map[e.key]);207 };208 209 const pct = Math.round(value * 100);210 211 return (212 <div213 ref={frameRef}214 className={cn(215 "group/compare relative w-full cursor-ew-resize overflow-hidden rounded-[20px]",216 "bg-(--color-paper-2) touch-pan-y select-none",217 "shadow-[0_1px_2px_rgba(0,0,0,0.05),0_12px_32px_rgba(0,0,0,0.08)]",218 "outline outline-1 -outline-offset-1 outline-black/8 dark:outline-white/10",219 className,220 )}221 style={{ aspectRatio }}222 onPointerDown={onPointerDown}223 onPointerMove={onPointerMove}224 onPointerUp={onPointerUp}225 onPointerCancel={onPointerUp}226 onPointerEnter={() => !dragging.current && setActive(0.5)}227 onPointerLeave={() => !dragging.current && setActive(0)}228 role="group"229 aria-labelledby={labelId}230 >231 <span id={labelId} className="sr-only">232 Image comparison, {pct}% {beforeLabel.toLowerCase()} revealed233 </span>234 235 {/* After (base) */}236 <BlurUpImage src={afterSrc} alt={afterAlt} reduce={reduce} />237 238 {/* Before (clipped) */}239 <motion.div className="absolute inset-0 will-change-[clip-path]" style={{ clipPath: clip }}>240 <BlurUpImage src={beforeSrc} alt={beforeAlt} reduce={reduce} />241 </motion.div>242 243 {/* Frosted seam: a narrow band of backdrop blur that rides the divider */}244 <motion.div245 aria-hidden246 className="pointer-events-none absolute inset-y-0 z-1"247 style={{248 left,249 width: seamWidth,250 x: "-50%",251 backdropFilter: seamFilter,252 WebkitBackdropFilter: seamFilter,253 maskImage: "linear-gradient(to right, transparent, black 35%, black 65%, transparent)",254 WebkitMaskImage: "linear-gradient(to right, transparent, black 35%, black 65%, transparent)",255 }}256 />257 258 {/* Divider + handle */}259 <motion.div260 className="pointer-events-none absolute inset-y-0 z-2 w-[2px] bg-white"261 style={{ left, x: "-50%", boxShadow: lineGlow }}262 >263 <motion.div264 role="slider"265 tabIndex={0}266 aria-label="Comparison position"267 aria-valuemin={0}268 aria-valuemax={100}269 aria-valuenow={pct}270 aria-valuetext={`${pct}% ${beforeLabel}`}271 onKeyDown={onKeyDown}272 onFocus={() => setActive(0.5)}273 onBlur={() => !dragging.current && setActive(0)}274 style={{ scale: handleScale, x: "-50%", y: "-50%" }}275 className={cn(276 "pointer-events-auto absolute top-1/2 left-1/2 flex h-11 w-11 items-center justify-center gap-[3px] rounded-full",277 "bg-white/70 text-neutral-900 backdrop-blur-xl backdrop-saturate-150",278 "shadow-[inset_0_1px_0_rgba(255,255,255,0.9),0_0_0_1px_rgba(0,0,0,0.08),0_2px_6px_rgba(0,0,0,0.12),0_10px_28px_rgba(0,0,0,0.18)]",279 "outline-none focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-2",280 isDragging ? "cursor-grabbing" : "cursor-grab",281 )}282 >283 <motion.svg style={{ x: arrowSpreadNeg }} width="7" height="12" viewBox="0 0 7 12" fill="none" aria-hidden>284 <path d="M6 1L1.5 6 6 11" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />285 </motion.svg>286 <motion.svg style={{ x: arrowSpread }} width="7" height="12" viewBox="0 0 7 12" fill="none" aria-hidden>287 <path d="M1 1l4.5 5L1 11" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />288 </motion.svg>289 </motion.div>290 </motion.div>291 292 <Chip side="left" opacity={beforeChip} filter={beforeChipBlur}>293 {beforeLabel}294 </Chip>295 <Chip side="right" opacity={afterChip} filter={afterChipBlur}>296 {afterLabel}297 </Chip>298 </div>299 );300}301 302function Chip({303 side,304 opacity,305 filter,306 children,307}: {308 side: "left" | "right";309 opacity: MotionValue<number>;310 filter: MotionValue<string>;311 children: React.ReactNode;312}) {313 return (314 <motion.span315 aria-hidden316 style={{ opacity, filter }}317 className={cn(318 "pointer-events-none absolute top-3 z-3 inline-flex h-6 items-center rounded-full px-2.5",319 "bg-neutral-900/55 text-[11px] font-medium tracking-[0.02em] text-white backdrop-blur-md",320 "shadow-[inset_0_0_0_1px_rgba(255,255,255,0.18)]",321 side === "left" ? "left-3" : "right-3",322 )}323 >324 {children}325 </motion.span>326 );327}328 329/** Starts soft and slightly zoomed, then sharpens once the bytes arrive. */330function BlurUpImage({ src, alt, reduce }: { src: string; alt: string; reduce: boolean | null }) {331 const ref = useRef<HTMLImageElement>(null);332 const [loaded, setLoaded] = useState(false);333 334 // Cached images can finish before hydration attaches onLoad.335 useEffect(() => {336 if (ref.current?.complete && ref.current.naturalWidth > 0) setLoaded(true);337 }, [src]);338 339 return (340 // eslint-disable-next-line @next/next/no-img-element341 <img342 ref={ref}343 src={src}344 alt={alt}345 draggable={false}346 decoding="async"347 onLoad={() => setLoaded(true)}348 className={cn(349 "absolute inset-0 h-full w-full object-cover object-top",350 !reduce && "transition-[filter,transform,opacity] duration-700 ease-[cubic-bezier(0.23,1,0.32,1)]",351 loaded ? "blur-0 scale-100 opacity-100" : "scale-[1.04] opacity-0 blur-xl",352 )}353 />354 );355}
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. |
| beforeLabel | string | No | "Before" | Chip text over the before side. |
| afterLabel | string | No | "After" | Chip text over the after side. |
| hint | boolean | No | true | Play a one-time sweep when the frame first scrolls into view. |
| 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. - Keep the handle legible on light and dark images: a frosted white chip with dark arrows, plus a hairline on the divider.
- Blur only a narrow band (backdrop-filter on a masked strip), never the whole image.