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.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,  useMotionValue,  useReducedMotion,  useTransform,} from "motion/react";import { cn } from "@/lib/utils";
export type ImageCompareProps = {  beforeSrc: string;  afterSrc: string;  beforeAlt?: string;  afterAlt?: string;  /** 0–1 initial position */  defaultPosition?: number;  position?: number;  onPositionChange?: (value: number) => void;  className?: string;  /** Aspect ratio utility, 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 };
export function ImageCompare({  beforeSrc,  afterSrc,  beforeAlt = "Before",  afterAlt = "After",  defaultPosition = 0.5,  position: positionProp,  onPositionChange,  className,  aspectRatio = "16 / 10",}: ImageCompareProps) {  const reduce = useReducedMotion();  const labelId = useId();  const frameRef = useRef<HTMLDivElement>(null);  const dragging = useRef(false);  const isControlled = positionProp !== undefined;  const [uncontrolled, setUncontrolled] = useState(defaultPosition);  const value = isControlled ? positionProp : uncontrolled;
  const mv = useMotionValue(value);  const clip = useTransform(mv, (v) => `inset(0 ${100 - v * 100}% 0 0)`);  const handleLeft = useTransform(mv, (v) => `${v * 100}%`);
  useEffect(() => {    mv.set(value);  }, [value, mv]);
  const setValue = useCallback(    (next: number, withSpring = false) => {      const clamped = Math.min(1, Math.max(0, next));      if (!isControlled) setUncontrolled(clamped);      onPositionChange?.(clamped);      if (withSpring && !reduce) {        animate(mv, clamped, SPRING);      } else {        mv.set(clamped);      }    },    [isControlled, onPositionChange, mv, reduce],  );
  const clientToValue = useCallback((clientX: number) => {    const el = frameRef.current;    if (!el) return 0.5;    const rect = el.getBoundingClientRect();    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) => {    e.currentTarget.setPointerCapture(e.pointerId);    dragging.current = true;    const v = clientToValue(e.clientX);    setValue(Math.min(1, Math.max(0, v)));  };
  const onPointerMove = (e: React.PointerEvent) => {    if (!dragging.current) return;    const raw = clientToValue(e.clientX);    // Live rubber-band visually via mv, commit clamped on release    if (raw < 0 || raw > 1) {      mv.set(raw);    } else {      setValue(raw);    }  };
  const onPointerUp = (e: React.PointerEvent) => {    if (!dragging.current) return;    dragging.current = false;    try {      e.currentTarget.releasePointerCapture(e.pointerId);    } catch {      /* noop */    }    const clamped = Math.min(1, Math.max(0, mv.get()));    setValue(clamped, true);  };
  const pct = Math.round(value * 100);
  return (    <div      ref={frameRef}      className={cn(        "relative w-full overflow-hidden rounded-lg",        "border border-(--color-rule) bg-(--color-paper-2)",        "touch-none select-none",        "outline outline-1 outline-black/6 dark:outline-white/10",        className,      )}      style={{ aspectRatio }}      onPointerDown={onPointerDown}      onPointerMove={onPointerMove}      onPointerUp={onPointerUp}      onPointerCancel={onPointerUp}      role="group"      aria-labelledby={labelId}    >      <span id={labelId} className="sr-only">        Image comparison slider, {pct}% before revealed      </span>
      {/* After (base) */}      {/* eslint-disable-next-line @next/next/no-img-element */}      <img        src={afterSrc}        alt={afterAlt}        draggable={false}        className="absolute inset-0 h-full w-full object-cover"      />
      {/* Before (clipped) */}      <motion.div        className="absolute inset-0 will-change-[clip-path]"        style={{ clipPath: clip }}      >        {/* eslint-disable-next-line @next/next/no-img-element */}        <img          src={beforeSrc}          alt={beforeAlt}          draggable={false}          className="absolute inset-0 h-full w-full object-cover"        />      </motion.div>
      {/* Divider + handle */}      <motion.div        className="absolute inset-y-0 z-2 w-px bg-white/90 shadow-[0_0_0_1px_rgba(0,0,0,0.15)]"        style={{          left: handleLeft,          x: "-50%",        }}      >        <div          className={cn(            "absolute top-1/2 left-1/2 flex size-10 -translate-x-1/2 -translate-y-1/2",            "items-center justify-center rounded-full",            "border border-black/10 bg-(--color-paper)",            "shadow-[0_4px_16px_rgba(0,0,0,0.18)]",            "transition-transform duration-100 ease-[cubic-bezier(0.23,1,0.32,1)]",            "active:scale-[0.96]",          )}          role="slider"          aria-valuemin={0}          aria-valuemax={100}          aria-valuenow={pct}          aria-label="Comparison position"          tabIndex={0}          onKeyDown={(e) => {            const step = e.shiftKey ? 0.1 : 0.02;            if (e.key === "ArrowLeft" || e.key === "ArrowDown") {              e.preventDefault();              setValue(value - step, true);            }            if (e.key === "ArrowRight" || e.key === "ArrowUp") {              e.preventDefault();              setValue(value + step, true);            }            if (e.key === "Home") {              e.preventDefault();              setValue(0, true);            }            if (e.key === "End") {              e.preventDefault();              setValue(1, true);            }          }}        >          <span className="flex gap-0.5" aria-hidden>            <span className="h-3 w-0.5 rounded-full bg-(--color-ink-muted)" />            <span className="h-3 w-0.5 rounded-full bg-(--color-ink-muted)" />          </span>        </div>      </motion.div>
      <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">        Before      </span>      <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">        After      </span>    </div>  );}

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
beforeSrcstringYesImage revealed on the left of the divider.
afterSrcstringYesBase image under the clip.
defaultPositionnumberNo0.5Initial position from 0–1.
positionnumberNoControlled position from 0–1.
onPositionChange(value: number) => voidNoFires 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.