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.json

Install dependencies:

$ pnpm add motion

Add the utility function for class merging:

lib/utils.ts
tsx
import { ClassValue, clsx } from "clsx";import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {  return twMerge(clsx(inputs));}

Copy the component code into your project:

components/ui/image-compare.tsx
tsx
"use client";
import React, { useCallback, useEffect, useId, useRef, useState } from "react";import {  animate,  motion,  useInView,  useMotionValue,  useReducedMotion,  useTransform,  useVelocity,  type AnimationPlaybackControls,  type MotionValue,} from "motion/react";import { cn } from "@/lib/utils";
export type ImageCompareProps = {  beforeSrc: string;  afterSrc: string;  beforeAlt?: string;  afterAlt?: string;  /** Chip text over each side. */  beforeLabel?: string;  afterLabel?: string;  /** 0–1 initial position */  defaultPosition?: number;  position?: number;  onPositionChange?: (value: number) => void;  /** Play a one-time sweep when the frame first scrolls into view. */  hint?: boolean;  className?: string;  /** CSS aspect-ratio, e.g. "16 / 9" */  aspectRatio?: string;};
function rubberband(overshoot: number, dimension: number, constant = 0.55) {  return (    (overshoot * dimension * constant) /    (dimension + constant * Math.abs(overshoot))  );}
const SPRING = { type: "spring" as const, bounce: 0, duration: 0.35 };const JUMP = { type: "spring" as const, bounce: 0, duration: 0.3 };const clampPosition = (value: number) =>  Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 0.5;
export function ImageCompare({  beforeSrc,  afterSrc,  beforeAlt = "Before",  afterAlt = "After",  beforeLabel = "Before",  afterLabel = "After",  defaultPosition = 0.5,  position: positionProp,  onPositionChange,  hint = true,  className,  aspectRatio = "16 / 10",}: ImageCompareProps) {  const reduce = useReducedMotion();  const labelId = useId();  const frameRef = useRef<HTMLDivElement>(null);  const dragging = useRef(false);  const interacted = useRef(false);  const anim = useRef<AnimationPlaybackControls | null>(null);  const isControlled = positionProp !== undefined;  const [uncontrolled, setUncontrolled] = useState(() => clampPosition(defaultPosition));  const value = clampPosition(isControlled ? positionProp : uncontrolled);  const [isDragging, setIsDragging] = useState(false);
  /* ── Motion values ────────────────────────────────────── */
  const mv = useMotionValue(value);  const clip = useTransform(mv, (v) => `inset(0 ${100 - v * 100}% 0 0)`);  const left = useTransform(mv, (v) => `${v * 100}%`);
  // 0 idle → 0.5 hover → 1 dragging. Drives the seam, handle and arrows.  const active = useMotionValue(0);  const setActive = (to: number) => {    if (reduce) active.set(to);    else animate(active, to, { duration: 0.22, ease: [0.23, 1, 0.32, 1] });  };
  // The seam frosts more the faster it moves, like motion blur behind glass.  const velocity = useVelocity(mv);  const seamBlur = useTransform(() => {    const speed = Math.min(Math.abs(velocity.get()), 4);    return Math.min(10, active.get() * 4 + speed * 1.6);  });  const seamFilter = useTransform(seamBlur, (b) => `blur(${b.toFixed(2)}px) saturate(1.4)`);  const seamWidth = useTransform(() => 28 + active.get() * 20 + Math.min(Math.abs(velocity.get()), 4) * 10);  const handleScale = useTransform(active, [0, 0.5, 1], [1, 1.04, 1.1]);  const arrowSpread = useTransform(active, [0, 1], [0, 2.5]);  const arrowSpreadNeg = useTransform(arrowSpread, (v) => -v);  const lineGlow = useTransform(    active,    // Dark hairline keeps the line visible on light images; the glow on dark ones.    (a) =>      `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)})`,  );
  // Each chip blurs away as its side gets covered.  const beforeChip = useTransform(mv, [0.06, 0.2], [0, 1]);  const afterChip = useTransform(mv, [0.8, 0.94], [1, 0]);  const beforeChipBlur = useTransform(beforeChip, (o) => `blur(${((1 - o) * 4).toFixed(1)}px)`);  const afterChipBlur = useTransform(afterChip, (o) => `blur(${((1 - o) * 4).toFixed(1)}px)`);
  useEffect(() => {    if (!dragging.current) mv.set(value);    // eslint-disable-next-line react-hooks/exhaustive-deps  }, [value]);
  const commit = useCallback(    (next: number) => {      const clamped = clampPosition(next);      if (!isControlled) setUncontrolled(clamped);      onPositionChange?.(clamped);      return clamped;    },    [isControlled, onPositionChange],  );
  const springTo = (target: number, transition = SPRING) => {    const clamped = commit(target);    anim.current?.stop();    if (reduce) mv.set(clamped);    else anim.current = animate(mv, clamped, transition);  };
  /* ── One-time hint sweep ──────────────────────────────── */
  const inView = useInView(frameRef, { once: true, amount: 0.6 });  useEffect(() => {    if (!inView || !hint || reduce || interacted.current) return;    const start = mv.get();    anim.current = animate(mv, [start, start + 0.12, start - 0.08, start], {      duration: 1.5,      ease: [0.45, 0, 0.2, 1],      times: [0, 0.35, 0.7, 1],      delay: 0.25,    });    // eslint-disable-next-line react-hooks/exhaustive-deps  }, [inView]);
  /* ── Pointer ──────────────────────────────────────────── */
  const clientToValue = useCallback((clientX: number) => {    const el = frameRef.current;    if (!el) return 0.5;    const rect = el.getBoundingClientRect();    if (rect.width <= 0) return 0.5;    const raw = (clientX - rect.left) / rect.width;    if (raw < 0) return -rubberband(-raw * rect.width, rect.width) / rect.width;    if (raw > 1) return 1 + rubberband((raw - 1) * rect.width, rect.width) / rect.width;    return raw;  }, []);
  const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {    if (e.button !== 0 || !e.isPrimary) return;    interacted.current = true;    dragging.current = true;    setIsDragging(true);    setActive(1);    try {      e.currentTarget.setPointerCapture(e.pointerId);    } catch {      /* pointer already released */    }    // Tapping away from the divider glides it over instead of teleporting.    springTo(clientToValue(e.clientX), JUMP);  };
  const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {    if (!dragging.current) return;    anim.current?.stop();    const raw = clientToValue(e.clientX);    // Rubber-band live past the edges; commit only in-bounds values.    mv.set(raw);    if (raw >= 0 && raw <= 1) commit(raw);  };
  const onPointerUp = () => {    if (!dragging.current) return;    dragging.current = false;    setIsDragging(false);    setActive(0);    const current = mv.get();    if (current < 0 || current > 1) springTo(current);  };
  const onKeyDown = (e: React.KeyboardEvent) => {    const step = e.shiftKey ? 0.1 : 0.02;    const map: Record<string, number> = {      ArrowLeft: value - step,      ArrowDown: value - step,      ArrowRight: value + step,      ArrowUp: value + step,      Home: 0,      End: 1,    };    if (!(e.key in map)) return;    e.preventDefault();    interacted.current = true;    springTo(map[e.key]);  };
  const pct = Math.round(value * 100);
  return (    <div      ref={frameRef}      className={cn(        "group/compare relative w-full cursor-ew-resize overflow-hidden rounded-[20px]",        "bg-(--color-paper-2) touch-pan-y select-none",        "shadow-[0_1px_2px_rgba(0,0,0,0.05),0_12px_32px_rgba(0,0,0,0.08)]",        "outline outline-1 -outline-offset-1 outline-black/8 dark:outline-white/10",        className,      )}      style={{ aspectRatio }}      onPointerDown={onPointerDown}      onPointerMove={onPointerMove}      onPointerUp={onPointerUp}      onPointerCancel={onPointerUp}      onPointerEnter={() => !dragging.current && setActive(0.5)}      onPointerLeave={() => !dragging.current && setActive(0)}      role="group"      aria-labelledby={labelId}    >      <span id={labelId} className="sr-only">        Image comparison, {pct}% {beforeLabel.toLowerCase()} revealed      </span>
      {/* After (base) */}      <BlurUpImage src={afterSrc} alt={afterAlt} reduce={reduce} />
      {/* Before (clipped) */}      <motion.div className="absolute inset-0 will-change-[clip-path]" style={{ clipPath: clip }}>        <BlurUpImage src={beforeSrc} alt={beforeAlt} reduce={reduce} />      </motion.div>
      {/* Frosted seam: a narrow band of backdrop blur that rides the divider */}      <motion.div        aria-hidden        className="pointer-events-none absolute inset-y-0 z-1"        style={{          left,          width: seamWidth,          x: "-50%",          backdropFilter: seamFilter,          WebkitBackdropFilter: seamFilter,          maskImage: "linear-gradient(to right, transparent, black 35%, black 65%, transparent)",          WebkitMaskImage: "linear-gradient(to right, transparent, black 35%, black 65%, transparent)",        }}      />
      {/* Divider + handle */}      <motion.div        className="pointer-events-none absolute inset-y-0 z-2 w-[2px] bg-white"        style={{ left, x: "-50%", boxShadow: lineGlow }}      >        <motion.div          role="slider"          tabIndex={0}          aria-label="Comparison position"          aria-valuemin={0}          aria-valuemax={100}          aria-valuenow={pct}          aria-valuetext={`${pct}% ${beforeLabel}`}          onKeyDown={onKeyDown}          onFocus={() => setActive(0.5)}          onBlur={() => !dragging.current && setActive(0)}          style={{ scale: handleScale, x: "-50%", y: "-50%" }}          className={cn(            "pointer-events-auto absolute top-1/2 left-1/2 flex h-11 w-11 items-center justify-center gap-[3px] rounded-full",            "bg-white/70 text-neutral-900 backdrop-blur-xl backdrop-saturate-150",            "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)]",            "outline-none focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-2",            isDragging ? "cursor-grabbing" : "cursor-grab",          )}        >          <motion.svg style={{ x: arrowSpreadNeg }} width="7" height="12" viewBox="0 0 7 12" fill="none" aria-hidden>            <path d="M6 1L1.5 6 6 11" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />          </motion.svg>          <motion.svg style={{ x: arrowSpread }} width="7" height="12" viewBox="0 0 7 12" fill="none" aria-hidden>            <path d="M1 1l4.5 5L1 11" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />          </motion.svg>        </motion.div>      </motion.div>
      <Chip side="left" opacity={beforeChip} filter={beforeChipBlur}>        {beforeLabel}      </Chip>      <Chip side="right" opacity={afterChip} filter={afterChipBlur}>        {afterLabel}      </Chip>    </div>  );}
function Chip({  side,  opacity,  filter,  children,}: {  side: "left" | "right";  opacity: MotionValue<number>;  filter: MotionValue<string>;  children: React.ReactNode;}) {  return (    <motion.span      aria-hidden      style={{ opacity, filter }}      className={cn(        "pointer-events-none absolute top-3 z-3 inline-flex h-6 items-center rounded-full px-2.5",        "bg-neutral-900/55 text-[11px] font-medium tracking-[0.02em] text-white backdrop-blur-md",        "shadow-[inset_0_0_0_1px_rgba(255,255,255,0.18)]",        side === "left" ? "left-3" : "right-3",      )}    >      {children}    </motion.span>  );}
/** Starts soft and slightly zoomed, then sharpens once the bytes arrive. */function BlurUpImage({ src, alt, reduce }: { src: string; alt: string; reduce: boolean | null }) {  const ref = useRef<HTMLImageElement>(null);  const [loaded, setLoaded] = useState(false);
  // Cached images can finish before hydration attaches onLoad.  useEffect(() => {    if (ref.current?.complete && ref.current.naturalWidth > 0) setLoaded(true);  }, [src]);
  return (    // eslint-disable-next-line @next/next/no-img-element    <img      ref={ref}      src={src}      alt={alt}      draggable={false}      decoding="async"      onLoad={() => setLoaded(true)}      className={cn(        "absolute inset-0 h-full w-full object-cover object-top",        !reduce && "transition-[filter,transform,opacity] duration-700 ease-[cubic-bezier(0.23,1,0.32,1)]",        loaded ? "blur-0 scale-100 opacity-100" : "scale-[1.04] opacity-0 blur-xl",      )}    />  );}

Adjust import paths according to your folder structure.


Basic Usage

tsx
import { ImageCompare } from "@/components/ui/image-compare";
<ImageCompare  beforeSrc="/before.png"  afterSrc="/after.png"  defaultPosition={0.5}/>

Props

PropTypeRequiredDefaultDescription
beforeSrcstringYes—Image revealed on the left of the divider.
afterSrcstringYes—Base image under the clip.
beforeLabelstringNo"Before"Chip text over the before side.
afterLabelstringNo"After"Chip text over the after side.
hintbooleanNotruePlay a one-time sweep when the frame first scrolls into view.
defaultPositionnumberNo0.5Initial position from 0–1.
positionnumberNo—Controlled position from 0–1.
onPositionChange(value: number) => voidNo—Fires as the scrubber moves (clamped).
aspectRatiostringNo"16 / 10"CSS aspect-ratio for the frame.

Best Practices

  1. Track on pointer-down (not click) so scrubbing feels direct.
  2. Animate clip-path via Motion values — keep the handle on transform.
  3. Rubber-band while dragging past edges; spring settle only on release.
  4. Expose a real role="slider" on the handle for keyboard users.
  5. Keep the handle legible on light and dark images: a frosted white chip with dark arrows, plus a hairline on the divider.
  6. Blur only a narrow band (backdrop-filter on a masked strip), never the whole image.