
Shadcn Image Diff Component
A powerful and customizable React component to compare two images with a slider. Built for shadcn/ui.

Original

Edited
Examples

Edited

Original
Basic Usage
tsx
<ImageDiff
leftImage="/original.jpeg"
rightImage="/new.png"
leftLabel="Original"
rightLabel="Edited"
initialSliderPosition={25}
/>
The source code for the ImageDiff component.
image-diff.tsx
tsx
"use client";
import { cva, type VariantProps } from "class-variance-authority";
import { GripVertical, ZoomIn } from "lucide-react";
import * as React from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
const imageDiffVariants = cva("relative w-full select-none", {
variants: {
variant: {
default: "border-border bg-background rounded-lg border",
},
},
defaultVariants: {
variant: "default",
},
});
const useImageDiffSlider = (
setSliderPosition: (percentage: number) => void,
containerRef: React.RefObject<HTMLDivElement | null>
) => {
const [isDragging, setIsDragging] = React.useState(false);
const updateSliderPosition = React.useCallback(
(e: React.MouseEvent<HTMLDivElement> | MouseEvent) => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const percentage = Math.min(Math.max((x / rect.width) * 100, 0), 100);
setSliderPosition(percentage);
},
[containerRef, setSliderPosition]
);
const handleMouseDown = React.useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
updateSliderPosition(e);
},
[updateSliderPosition]
);
React.useEffect(() => {
const handleMouseUp = () => setIsDragging(false);
const handleMouseMove = (e: MouseEvent) => updateSliderPosition(e);
if (isDragging) {
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}
}, [isDragging, updateSliderPosition]);
return { handleMouseDown, isDragging };
};
const magnifierPresets = {
medium: { size: 150, zoom: 2.5 },
large: { size: 200, zoom: 3 },
xlarge: { size: 250, zoom: 3.5 },
};
type MagnifierLayout = "horizontal" | "vertical";
const useMagnifier = (enabled: boolean) => {
const [isMagnifierEnabled, setIsMagnifierEnabled] = React.useState(false);
const [mousePosition, setMousePosition] = React.useState<{ x: number; y: number } | null>(null);
const [magnifierProfile, setMagnifierProfile] =
React.useState<keyof typeof magnifierPresets>("medium");
const [magnifierLayout, setMagnifierLayout] = React.useState<MagnifierLayout>("horizontal");
const handleMouseMoveForMagnifier = (e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
setMousePosition({
x: e.clientX - rect.left,
y: e.clientY - rect.top,
});
};
const handleMouseLeaveForMagnifier = () => {
setMousePosition(null);
};
const toggleMagnifier = () => {
setIsMagnifierEnabled((prev) => {
if (!prev) {
setMagnifierLayout("horizontal");
}
return !prev;
});
};
const handleLeftClick = () => {
setMagnifierLayout((prev) => (prev === "horizontal" ? "vertical" : "horizontal"));
};
const handleRightClick = (e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault();
setIsMagnifierEnabled(false);
};
const containerProps = enabled
? {
onMouseMove: isMagnifierEnabled ? handleMouseMoveForMagnifier : undefined,
onMouseLeave: isMagnifierEnabled ? handleMouseLeaveForMagnifier : undefined,
onClick: isMagnifierEnabled ? handleLeftClick : undefined,
onContextMenu: isMagnifierEnabled ? handleRightClick : undefined,
}
: {};
const { size: magnifierSize, zoom: magnifierZoom } = magnifierPresets[magnifierProfile];
return {
isMagnifierEnabled,
toggleMagnifier,
mousePosition,
containerProps,
magnifierSize,
magnifierZoom,
magnifierProfile,
setMagnifierProfile,
magnifierLayout,
};
};
const Magnifier = ({
image,
mousePosition,
containerRect,
position,
zoom = 2,
size = 100,
}: {
image: string;
mousePosition: { x: number; y: number };
containerRect: DOMRect;
position: "left" | "right" | "top" | "bottom";
zoom?: number;
size?: number;
}) => {
const { width, height } = containerRect;
const offset = 10;
const bgSize = `${width * zoom}px ${height * zoom}px`;
const bgPosX = -mousePosition.x * zoom + size / 2;
const bgPosY = -mousePosition.y * zoom + size / 2;
const style: React.CSSProperties = {
width: `${size}px`,
height: `${size}px`,
backgroundImage: `url(${image})`,
backgroundPosition: `${bgPosX}px ${bgPosY}px`,
backgroundSize: bgSize,
};
if (position === "left" || position === "right") {
const sideOffset = position === "left" ? -offset : offset;
style.left = `${mousePosition.x + sideOffset}px`;
style.top = `${mousePosition.y - size / 2}px`;
style.transform = position === "left" ? "translateX(-100%)" : "none";
} else {
const verticalOffset = position === "top" ? -offset : offset;
style.left = `${mousePosition.x - size / 2}px`;
style.top = `${mousePosition.y + verticalOffset}px`;
style.transform = position === "top" ? "translateY(-100%)" : "none";
}
return (
<div
className="pointer-events-none absolute z-20 rounded-full border-2 border-white bg-no-repeat shadow-lg"
style={style}
/>
);
};
export interface ImageDiffProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof imageDiffVariants> {
leftImage: string;
rightImage: string;
leftLabel?: string;
rightLabel?: string;
initialSliderPosition?: number;
showMagnifierButton?: boolean;
hideLabels?: boolean;
}
const ImageDiffControls = ({
isMagnifierEnabled,
magnifierProfile,
setMagnifierProfile,
toggleMagnifier,
showMagnifierButton,
}: {
isMagnifierEnabled: boolean;
magnifierProfile: keyof typeof magnifierPresets;
setMagnifierProfile: (profile: keyof typeof magnifierPresets) => void;
toggleMagnifier: () => void;
showMagnifierButton: boolean;
}) => (
<div className="absolute right-4 bottom-4 flex items-center gap-2">
{showMagnifierButton && (
<>
{isMagnifierEnabled && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="bg-secondary/80 text-secondary-foreground hover:bg-secondary/90 focus:ring-ring flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium transition-colors focus:ring-2 focus:outline-none">
{magnifierProfile === "medium" && "M"}
{magnifierProfile === "large" && "L"}
{magnifierProfile === "xlarge" && "XL"}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[120px]">
<DropdownMenuRadioGroup
value={magnifierProfile}
onValueChange={(value) =>
setMagnifierProfile(value as keyof typeof magnifierPresets)
}
>
<DropdownMenuRadioItem value="medium">Medium</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="large">Large</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="xlarge">
X-Large
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)}
<button
onClick={toggleMagnifier}
className={cn(
"bg-secondary/80 text-secondary-foreground hover:bg-secondary/90 flex h-8 w-8 items-center justify-center rounded-full transition-colors",
isMagnifierEnabled && "bg-accent text-accent-foreground"
)}
aria-label="Toggle Magnifier"
>
<ZoomIn className="h-4 w-4" />
</button>
</>
)}
</div>
);
type ImageDiffContentProps = {
leftImage: string;
rightImage: string;
leftLabel: string;
rightLabel: string;
hideLabels: boolean;
sliderPosition: number;
handleMouseDown: (e: React.MouseEvent<HTMLDivElement>) => void;
isMagnifierEnabled: boolean;
mousePosition: { x: number; y: number } | null;
magnifierSize: number;
magnifierZoom: number;
containerRect: DOMRect | null;
magnifierLayout: MagnifierLayout;
};
const ImageDiffContent = React.forwardRef<HTMLDivElement, ImageDiffContentProps>(
(
{
leftImage,
rightImage,
leftLabel,
rightLabel,
hideLabels,
sliderPosition,
handleMouseDown,
isMagnifierEnabled,
mousePosition,
magnifierSize,
magnifierZoom,
containerRect,
magnifierLayout,
},
ref
) => {
const labelSizeClasses = "text-sm";
return (
<div ref={ref} className={cn("relative w-full")}>
<img
src={leftImage}
alt={leftLabel}
className={cn("block w-full object-contain")}
draggable={false}
/>
{!hideLabels && (
<div
className={cn(
"bg-secondary text-secondary-foreground absolute top-4 right-4 rounded px-2 py-1 font-medium",
labelSizeClasses
)}
>
{leftLabel}
</div>
)}
<div
className="absolute inset-0 overflow-hidden"
style={{ clipPath: `inset(0 ${100 - sliderPosition}% 0 0)` }}
>
<img
src={rightImage}
alt={rightLabel}
className={cn("block w-full object-contain")}
draggable={false}
/>
{!hideLabels && (
<div
className={cn(
"bg-accent text-accent-foreground absolute top-4 left-4 rounded px-2 py-1 font-medium",
labelSizeClasses
)}
>
{rightLabel}
</div>
)}
</div>
<div
className="bg-muted-foreground absolute top-0 bottom-0 w-0.5 cursor-ew-resize shadow-lg"
style={{ left: `${sliderPosition}%`, height: "100%" }}
onMouseDown={handleMouseDown}
>
<div
className={cn(
"border-muted-foreground bg-background absolute top-1/2 left-1/2 flex -translate-x-1/2 -translate-y-1/2 transform cursor-ew-resize items-center justify-center rounded-full border shadow-lg transition-colors",
"h-8 w-8"
)}
>
<GripVertical className={cn("text-muted-foreground", "h-5 w-5")} />
</div>
</div>
{isMagnifierEnabled && mousePosition && containerRect && (
<>
<Magnifier
image={leftImage}
mousePosition={mousePosition}
containerRect={containerRect}
position={magnifierLayout === "horizontal" ? "left" : "top"}
size={magnifierSize}
zoom={magnifierZoom}
/>
<Magnifier
image={rightImage}
mousePosition={mousePosition}
containerRect={containerRect}
position={magnifierLayout === "horizontal" ? "right" : "bottom"}
size={magnifierSize}
zoom={magnifierZoom}
/>
</>
)}
</div>
);
}
);
ImageDiffContent.displayName = "ImageDiffContent";
const ImageDiff = React.forwardRef<HTMLDivElement, ImageDiffProps>(
(
{
className,
variant,
leftImage,
rightImage,
leftLabel = "Left",
rightLabel = "Right",
initialSliderPosition = 50,
showMagnifierButton = true,
hideLabels = false,
...props
},
ref
) => {
const containerRef = React.useRef<HTMLDivElement>(null);
React.useImperativeHandle(ref, () => containerRef.current!);
const [sliderPosition, setSliderPosition] = React.useState(initialSliderPosition);
const { handleMouseDown, isDragging } = useImageDiffSlider(setSliderPosition, containerRef);
const {
isMagnifierEnabled,
toggleMagnifier,
mousePosition,
containerProps,
magnifierSize,
magnifierZoom,
magnifierProfile,
setMagnifierProfile,
magnifierLayout,
} = useMagnifier(showMagnifierButton && !isDragging);
const containerRect = containerRef.current?.getBoundingClientRect() ?? null;
const contentProps = {
leftImage,
rightImage,
leftLabel,
rightLabel,
hideLabels,
sliderPosition,
handleMouseDown,
isMagnifierEnabled,
mousePosition,
magnifierSize,
magnifierZoom,
containerRect,
magnifierLayout,
};
return (
<div
ref={containerRef}
className={cn(imageDiffVariants({ variant, className }))}
{...props}
{...containerProps}
>
<ImageDiffContent {...contentProps} />
<ImageDiffControls
isMagnifierEnabled={isMagnifierEnabled}
magnifierProfile={magnifierProfile}
setMagnifierProfile={setMagnifierProfile}
toggleMagnifier={toggleMagnifier}
showMagnifierButton={showMagnifierButton}
/>
</div>
);
}
);
ImageDiff.displayName = "ImageDiff";
export { ImageDiff, imageDiffVariants };