ComponentsCreative

Image Comparison

Before/after image comparison slider for React. Drag handle, hover reveal, and lens modes. Perfect for photo editing, design, and AI image showcases.

Last updated on

Edit on GitHub

Drag Slider

import { ImageComparison } from "@/components/ui/image-comparison";
 
export function ImageComparisonDemo() {
  return (
    <ImageComparison
      beforeImage="https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800&h=500&fit=crop&sat=-100"
      afterImage="https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800&h=500&fit=crop"
      beforeLabel="Grayscale"
      afterLabel="Color"
      className="aspect-video w-full"
    />
  );
}

Hover Reveal

import { ImageComparisonHover } from "@/components/ui/image-comparison";
 
export function ImageComparisonHoverDemo() {
  return (
    <ImageComparisonHover
      beforeImage="https://images.unsplash.com/photo-1469474968028-56623f02e42e?w=600&h=400&fit=crop&sat=-100"
      afterImage="https://images.unsplash.com/photo-1469474968028-56623f02e42e?w=600&h=400&fit=crop"
      beforeLabel="Original"
      afterLabel="Enhanced"
      className="aspect-[3/2] w-full"
    />
  );
}

Fade Toggle

import { ImageComparisonFade } from "@/components/ui/image-comparison";
 
export function ImageComparisonFadeDemo() {
  return (
    <ImageComparisonFade
      beforeImage="https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?w=600&h=400&fit=crop&sat=-100"
      afterImage="https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?w=600&h=400&fit=crop"
      beforeLabel="Before"
      afterLabel="After"
      className="aspect-[3/2] w-full"
    />
  );
}

Split View

import { ImageComparisonSplit } from "@/components/ui/image-comparison";
 
export function ImageComparisonSplitDemo() {
  return (
    <ImageComparisonSplit
      beforeImage="https://images.unsplash.com/photo-1501854140801-50d01698950b?w=600&h=350&fit=crop&sat=-100"
      afterImage="https://images.unsplash.com/photo-1501854140801-50d01698950b?w=600&h=350&fit=crop"
      beforeLabel="Original"
      afterLabel="Enhanced"
      className="aspect-[2/1] w-full"
      gap={8}
    />
  );
}

Lens Effect

import { ImageComparisonLens } from "@/components/ui/image-comparison";
 
export function ImageComparisonLensDemo() {
  return (
    <ImageComparisonLens
      beforeImage="https://images.unsplash.com/photo-1447752875215-b2761acb3c5d?w=800&h=450&fit=crop&sat=-100"
      afterImage="https://images.unsplash.com/photo-1447752875215-b2761acb3c5d?w=800&h=450&fit=crop"
      className="aspect-video w-full"
      lensSize={180}
    />
  );
}

Vertical Slider

import { ImageComparison } from "@/components/ui/image-comparison";
 
export function ImageComparisonVerticalDemo() {
  return (
    <div className="mx-auto max-w-md">
      <ImageComparison
        beforeImage="https://images.unsplash.com/photo-1433086966358-54859d0ed716?w=500&h=700&fit=crop&sat=-100"
        afterImage="https://images.unsplash.com/photo-1433086966358-54859d0ed716?w=500&h=700&fit=crop"
        beforeLabel="Top"
        afterLabel="Bottom"
        orientation="vertical"
        className="aspect-[3/4] w-full"
      />
    </div>
  );
}

Installation

CLI

npx shadcn@latest add "https://jolyui.dev/r/image-comparison"

Manual

Install the following dependencies:

npm install motion

Copy and paste the following code into your project. component/ui/image-comparison.tsx

import { GripHorizontal, GripVertical } from "lucide-react";
import { motion, useMotionValue, useTransform } from "motion/react";
import type React from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
 
// Basic Comparison Slider
interface ImageComparisonProps {
  beforeImage: string;
  afterImage: string;
  beforeLabel?: string;
  afterLabel?: string;
  className?: string;
  initialPosition?: number;
  orientation?: "horizontal" | "vertical";
  showLabels?: boolean;
  sliderColor?: string;
}
 
export function ImageComparison({
  beforeImage,
  afterImage,
  beforeLabel = "Before",
  afterLabel = "After",
  className,
  initialPosition = 50,
  orientation = "horizontal",
  showLabels = true,
  sliderColor = "hsl(var(--background))",
}: ImageComparisonProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const [position, setPosition] = useState(initialPosition);
  const [isDragging, setIsDragging] = useState(false);
 
  const handleMove = useCallback(
    (clientX: number, clientY: number) => {
      if (!containerRef.current) return;
 
      const rect = containerRef.current.getBoundingClientRect();
      let newPosition: number;
 
      if (orientation === "horizontal") {
        newPosition = ((clientX - rect.left) / rect.width) * 100;
      } else {
        newPosition = ((clientY - rect.top) / rect.height) * 100;
      }
 
      setPosition(Math.max(0, Math.min(100, newPosition)));
    },
    [orientation],
  );
 
  const handleMouseDown = (e: React.MouseEvent) => {
    e.preventDefault();
    setIsDragging(true);
  };
 
  const handleTouchStart = () => {
    setIsDragging(true);
  };
 
  useEffect(() => {
    const handleMouseMove = (e: MouseEvent) => {
      if (isDragging) {
        handleMove(e.clientX, e.clientY);
      }
    };
 
    const handleTouchMove = (e: TouchEvent) => {
      if (isDragging && e.touches[0]) {
        handleMove(e.touches[0].clientX, e.touches[0].clientY);
      }
    };
 
    const handleEnd = () => {
      setIsDragging(false);
    };
 
    if (isDragging) {
      window.addEventListener("mousemove", handleMouseMove);
      window.addEventListener("mouseup", handleEnd);
      window.addEventListener("touchmove", handleTouchMove);
      window.addEventListener("touchend", handleEnd);
    }
 
    return () => {
      window.removeEventListener("mousemove", handleMouseMove);
      window.removeEventListener("mouseup", handleEnd);
      window.removeEventListener("touchmove", handleTouchMove);
      window.removeEventListener("touchend", handleEnd);
    };
  }, [isDragging, handleMove]);
 
  return (
    <div
      ref={containerRef}
      className={cn(
        "relative select-none overflow-hidden rounded-xl",
        className,
      )}
    >
      {/* After Image (Background) */}
      {/* biome-ignore lint/performance/noImgElement: next/image causes ESM issues with fumadocs-mdx */}
      <img
        src={afterImage}
        alt={afterLabel}
        className="h-full w-full object-cover"
        draggable={false}
      />
 
      {/* Before Image (Clipped) */}
      <div
        className="absolute inset-0 overflow-hidden"
        style={{
          clipPath:
            orientation === "horizontal"
              ? `inset(0 ${100 - position}% 0 0)`
              : `inset(0 0 ${100 - position}% 0)`,
        }}
      >
        {/* biome-ignore lint/performance/noImgElement: next/image causes ESM issues with fumadocs-mdx */}
        <img
          src={beforeImage}
          alt={beforeLabel}
          className="h-full w-full object-cover"
          draggable={false}
        />
      </div>
 
      {/* Slider Line */}
      <div
        className={cn(
          "absolute z-10",
          orientation === "horizontal"
            ? "-translate-x-1/2 top-0 h-full w-0.5"
            : "-translate-y-1/2 left-0 h-0.5 w-full",
        )}
        style={{
          [orientation === "horizontal" ? "left" : "top"]: `${position}%`,
          backgroundColor: sliderColor,
          boxShadow: "0 0 10px rgba(0,0,0,0.3)",
        }}
      />
 
      {/* Slider Handle */}
      <motion.div
        className={cn(
          "absolute z-20 flex cursor-grab items-center justify-center rounded-full border-2 bg-background shadow-lg active:cursor-grabbing",
          orientation === "horizontal"
            ? "-translate-x-1/2 -translate-y-1/2 h-10 w-10"
            : "-translate-x-1/2 -translate-y-1/2 h-10 w-10",
        )}
        style={{
          [orientation === "horizontal" ? "left" : "left"]:
            orientation === "horizontal" ? `${position}%` : "50%",
          [orientation === "horizontal" ? "top" : "top"]:
            orientation === "horizontal" ? "50%" : `${position}%`,
          borderColor: sliderColor,
        }}
        onMouseDown={handleMouseDown}
        onTouchStart={handleTouchStart}
        whileHover={{ scale: 1.1 }}
        whileTap={{ scale: 0.95 }}
      >
        {orientation === "horizontal" ? (
          <GripVertical className="h-5 w-5 text-muted-foreground" />
        ) : (
          <GripHorizontal className="h-5 w-5 text-muted-foreground" />
        )}
      </motion.div>
 
      {/* Labels */}
      {showLabels && (
        <>
          <div
            className={cn(
              "absolute rounded-md bg-background/80 px-2 py-1 font-medium text-xs backdrop-blur-sm",
              orientation === "horizontal" ? "top-3 left-3" : "top-3 left-3",
            )}
          >
            {beforeLabel}
          </div>
          <div
            className={cn(
              "absolute rounded-md bg-background/80 px-2 py-1 font-medium text-xs backdrop-blur-sm",
              orientation === "horizontal"
                ? "top-3 right-3"
                : "bottom-3 left-3",
            )}
          >
            {afterLabel}
          </div>
        </>
      )}
    </div>
  );
}
 
// Hover Reveal Comparison
interface ImageComparisonHoverProps {
  beforeImage: string;
  afterImage: string;
  beforeLabel?: string;
  afterLabel?: string;
  className?: string;
  showLabels?: boolean;
}
 
export function ImageComparisonHover({
  beforeImage,
  afterImage,
  beforeLabel = "Before",
  afterLabel = "After",
  className,
  showLabels = true,
}: ImageComparisonHoverProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const [position, setPosition] = useState(50);
 
  const handleMouseMove = (e: React.MouseEvent) => {
    if (!containerRef.current) return;
    const rect = containerRef.current.getBoundingClientRect();
    const x = ((e.clientX - rect.left) / rect.width) * 100;
    setPosition(Math.max(0, Math.min(100, x)));
  };
 
  const handleMouseLeave = () => {
    setPosition(50);
  };
 
  return (
    <div
      ref={containerRef}
      className={cn(
        "relative cursor-ew-resize select-none overflow-hidden rounded-xl",
        className,
      )}
      onMouseMove={handleMouseMove}
      onMouseLeave={handleMouseLeave}
      role="button"
      tabIndex={0}
    >
      {/* After Image */}
      {/* biome-ignore lint/performance/noImgElement: next/image causes ESM issues with fumadocs-mdx */}
      <img
        src={afterImage}
        alt={afterLabel}
        className="h-full w-full object-cover"
        draggable={false}
      />
 
      {/* Before Image */}
      <motion.div
        className="absolute inset-0 overflow-hidden"
        animate={{ clipPath: `inset(0 ${100 - position}% 0 0)` }}
        transition={{ type: "tween", duration: 0.1 }}
      >
        {/* biome-ignore lint/performance/noImgElement: next/image causes ESM issues with fumadocs-mdx */}
        <img
          src={beforeImage}
          alt={beforeLabel}
          className="h-full w-full object-cover"
          draggable={false}
        />
      </motion.div>
 
      {/* Divider Line */}
      {/* <motion.div
        className="absolute top-0 h-full w-0.5 bg-background"
        animate={{ left: `${position}%` }}
        transition={{ type: "tween", duration: 0.1 }}
        style={{ transform: "translateX(-50%)", boxShadow: "0 0 10px rgba(0,0,0,0.3)" }}
      /> */}
 
      {/* Labels */}
      {showLabels && (
        <>
          <div className="absolute top-3 left-3 rounded-md bg-background/80 px-2 py-1 font-medium text-xs backdrop-blur-sm">
            {beforeLabel}
          </div>
          <div className="absolute top-3 right-3 rounded-md bg-background/80 px-2 py-1 font-medium text-xs backdrop-blur-sm">
            {afterLabel}
          </div>
        </>
      )}
    </div>
  );
}
 
// Split View Comparison
interface ImageComparisonSplitProps {
  beforeImage: string;
  afterImage: string;
  beforeLabel?: string;
  afterLabel?: string;
  className?: string;
  gap?: number;
}
 
export function ImageComparisonSplit({
  beforeImage,
  afterImage,
  beforeLabel = "Before",
  afterLabel = "After",
  className,
  gap = 4,
}: ImageComparisonSplitProps) {
  return (
    <div
      className={cn("flex overflow-hidden rounded-xl", className)}
      style={{ gap }}
    >
      <div className="relative flex-1 overflow-hidden">
        {/* biome-ignore lint/performance/noImgElement: next/image causes ESM issues with fumadocs-mdx */}
        <img
          src={beforeImage}
          alt={beforeLabel}
          className="h-full w-full object-cover"
          draggable={false}
        />
        <div className="absolute bottom-3 left-3 rounded-md bg-background/80 px-2 py-1 font-medium text-xs backdrop-blur-sm">
          {beforeLabel}
        </div>
      </div>
      <div className="relative flex-1 overflow-hidden">
        {/* biome-ignore lint/performance/noImgElement: next/image causes ESM issues with fumadocs-mdx */}
        <img
          src={afterImage}
          alt={afterLabel}
          className="h-full w-full object-cover"
          draggable={false}
        />
        <div className="absolute right-3 bottom-3 rounded-md bg-background/80 px-2 py-1 font-medium text-xs backdrop-blur-sm">
          {afterLabel}
        </div>
      </div>
    </div>
  );
}
 
// Fade Toggle Comparison
interface ImageComparisonFadeProps {
  beforeImage: string;
  afterImage: string;
  beforeLabel?: string;
  afterLabel?: string;
  className?: string;
  showLabels?: boolean;
}
 
export function ImageComparisonFade({
  beforeImage,
  afterImage,
  beforeLabel = "Before",
  afterLabel = "After",
  className,
  showLabels = true,
}: ImageComparisonFadeProps) {
  const [showBefore, setShowBefore] = useState(true);
 
  return (
    <div
      className={cn(
        "group relative cursor-pointer select-none overflow-hidden rounded-xl",
        className,
      )}
      onClick={() => setShowBefore(!showBefore)}
      role="button"
      tabIndex={0}
    >
      {/* After Image */}
      {/* biome-ignore lint/performance/noImgElement: next/image causes ESM issues with fumadocs-mdx */}
      <img
        src={afterImage}
        alt={afterLabel}
        className="h-full w-full object-cover"
        draggable={false}
      />
 
      {/* Before Image with fade */}
      <motion.div
        className="absolute inset-0"
        animate={{ opacity: showBefore ? 1 : 0 }}
        transition={{ duration: 0.5 }}
      >
        {/* biome-ignore lint/performance/noImgElement: next/image causes ESM issues with fumadocs-mdx */}
        <img
          src={beforeImage}
          alt={beforeLabel}
          className="h-full w-full object-cover"
          draggable={false}
        />
      </motion.div>
 
      {/* Label */}
      {showLabels && (
        <motion.div
          className="-translate-x-1/2 absolute top-3 left-1/2 rounded-md bg-background/80 px-3 py-1.5 font-medium text-sm backdrop-blur-sm"
          key={showBefore ? "before" : "after"}
          initial={{ opacity: 0, y: -10 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ duration: 0.2 }}
        >
          {showBefore ? beforeLabel : afterLabel}
        </motion.div>
      )}
 
      {/* Click hint */}
      <div className="-translate-x-1/2 absolute bottom-3 left-1/2 rounded-md bg-background/80 px-2 py-1 text-muted-foreground text-xs opacity-0 backdrop-blur-sm transition-opacity group-hover:opacity-100">
        Click to toggle
      </div>
    </div>
  );
}
 
// Swipe Comparison
interface ImageComparisonSwipeProps {
  beforeImage: string;
  afterImage: string;
  beforeLabel?: string;
  afterLabel?: string;
  className?: string;
}
 
export function ImageComparisonSwipe({
  beforeImage,
  afterImage,
  beforeLabel = "Before",
  afterLabel = "After",
  className,
}: ImageComparisonSwipeProps) {
  const x = useMotionValue(0);
  const containerRef = useRef<HTMLDivElement>(null);
  const [containerWidth, setContainerWidth] = useState(0);
 
  useEffect(() => {
    if (containerRef.current) {
      setContainerWidth(containerRef.current.offsetWidth);
    }
  }, []);
 
  const clipPath = useTransform(
    x,
    [-containerWidth / 2, containerWidth / 2],
    [0, 100],
  );
  const displayClipPath = useTransform(
    clipPath,
    (v) => `inset(0 ${100 - (50 + v / 2)}% 0 0)`,
  );
  const linePosition = useTransform(clipPath, (v) => `${50 + v / 2}%`);
 
  return (
    <div
      ref={containerRef}
      className={cn(
        "relative select-none overflow-hidden rounded-xl",
        className,
      )}
    >
      {/* After Image */}
      {/* biome-ignore lint/performance/noImgElement: next/image causes ESM issues with fumadocs-mdx */}
      <img
        src={afterImage}
        alt={afterLabel}
        className="h-full w-full object-cover"
        draggable={false}
      />
 
      {/* Before Image */}
      <motion.div
        className="absolute inset-0 overflow-hidden"
        style={{ clipPath: displayClipPath }}
      >
        {/* biome-ignore lint/performance/noImgElement: next/image causes ESM issues with fumadocs-mdx */}
        <img
          src={beforeImage}
          alt={beforeLabel}
          className="h-full w-full object-cover"
          draggable={false}
        />
      </motion.div>
 
      {/* Slider Line */}
      <motion.div
        className="absolute top-0 h-full w-0.5 bg-background"
        style={{
          left: linePosition,
          transform: "translateX(-50%)",
          boxShadow: "0 0 10px rgba(0,0,0,0.3)",
        }}
      />
 
      {/* Draggable Handle */}
      <motion.div
        className="-translate-y-1/2 absolute top-1/2 left-1/2 z-20 flex h-12 w-12 cursor-grab items-center justify-center rounded-full border-2 border-background bg-background shadow-lg active:cursor-grabbing"
        drag="x"
        dragConstraints={{
          left: -containerWidth / 2 + 20,
          right: containerWidth / 2 - 20,
        }}
        dragElastic={0}
        style={{ x }}
        whileHover={{ scale: 1.1 }}
        whileTap={{ scale: 0.95 }}
      >
        <GripVertical className="h-5 w-5 text-muted-foreground" />
      </motion.div>
 
      {/* Labels */}
      <div className="absolute top-3 left-3 rounded-md bg-background/80 px-2 py-1 font-medium text-xs backdrop-blur-sm">
        {beforeLabel}
      </div>
      <div className="absolute top-3 right-3 rounded-md bg-background/80 px-2 py-1 font-medium text-xs backdrop-blur-sm">
        {afterLabel}
      </div>
    </div>
  );
}
 
// Lens Comparison (magnifying glass effect)
interface ImageComparisonLensProps {
  beforeImage: string;
  afterImage: string;
  className?: string;
  lensSize?: number;
}
 
export function ImageComparisonLens({
  beforeImage,
  afterImage,
  className,
  lensSize = 150,
}: ImageComparisonLensProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const [lensPosition, setLensPosition] = useState({ x: 50, y: 50 });
  const [isHovering, setIsHovering] = useState(false);
 
  const handleMouseMove = (e: React.MouseEvent) => {
    if (!containerRef.current) return;
    const rect = containerRef.current.getBoundingClientRect();
    const x = ((e.clientX - rect.left) / rect.width) * 100;
    const y = ((e.clientY - rect.top) / rect.height) * 100;
    setLensPosition({ x, y });
  };
 
  return (
    <div
      ref={containerRef}
      className={cn(
        "relative cursor-none select-none overflow-hidden rounded-xl",
        className,
      )}
      onMouseMove={handleMouseMove}
      onMouseEnter={() => setIsHovering(true)}
      onMouseLeave={() => setIsHovering(false)}
      role="button"
      tabIndex={0}
    >
      {/* Before Image (Background) */}
      {/* biome-ignore lint/performance/noImgElement: next/image causes ESM issues with fumadocs-mdx */}
      <img
        src={beforeImage}
        alt="Before"
        className="h-full w-full object-cover"
        draggable={false}
      />
 
      {/* Lens showing After Image */}
      <motion.div
        className="pointer-events-none absolute overflow-hidden rounded-full border-2 border-background shadow-2xl"
        style={{
          width: lensSize,
          height: lensSize,
          left: `calc(${lensPosition.x}% - ${lensSize / 2}px)`,
          top: `calc(${lensPosition.y}% - ${lensSize / 2}px)`,
        }}
        animate={{ opacity: isHovering ? 1 : 0, scale: isHovering ? 1 : 0.8 }}
        transition={{ duration: 0.2 }}
      >
        <div
          className="h-full w-full"
          style={{
            backgroundImage: `url(${afterImage})`,
            backgroundSize: containerRef.current
              ? `${containerRef.current.offsetWidth}px ${containerRef.current.offsetHeight}px`
              : "100% 100%",
            backgroundPosition: `${lensPosition.x}% ${lensPosition.y}%`,
          }}
        />
      </motion.div>
 
      {/* Labels */}
      <div className="absolute top-3 left-3 rounded-md bg-background/80 px-2 py-1 font-medium text-xs backdrop-blur-sm">
        Before
      </div>
      <div className="absolute top-3 right-3 rounded-md bg-background/80 px-2 py-1 font-medium text-xs backdrop-blur-sm">
        Hover to see After
      </div>
    </div>
  );
}
 
export type {
  ImageComparisonFadeProps,
  ImageComparisonHoverProps,
  ImageComparisonLensProps,
  ImageComparisonProps,
  ImageComparisonSplitProps,
  ImageComparisonSwipeProps,
};

API Reference

ImageComparison

Prop

Type

ImageComparisonHover

Prop

Type

ImageComparisonFade

Prop

Type

ImageComparisonSplit

Prop

Type

ImageComparisonLens

Prop

Type

Notes

  • All image comparison components support both horizontal and vertical orientations
  • The drag slider uses Framer Motion's drag constraints for smooth interaction
  • Hover effects use mouse tracking for responsive reveals
  • Lens effects create circular reveal masks using CSS clip-path
  • Split view components can be customized with gap spacing
  • All components are fully accessible and support keyboard navigation

How is this guide?

On this page