Components
Image Comparison
Compare two images with various interactive techniques including drag sliders, hover reveals, and lens effects.
Last updated on
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 motionCopy 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?