Krevo Logo

Shadcn Image Diff Component

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

Original
Original
Edited
Edited

Examples

Edited
Edited
Original
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 };