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
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?
Hover Preview
Link preview card on hover for React. Show images, descriptions, and metadata on link hover. Perfect for portfolios, blogs, and documentation sites.
Image Sphere
3D rotating image gallery sphere for React. Drag to rotate, click to expand. Built with CSS 3D transforms and spring physics. Perfect for portfolios.