Interactively compare two images with a draggable slider to reveal differences.
1'use client';
2import { cn } from '@/lib/utils';
3import { useState, createContext, useContext } from 'react';
4import {
5 motion,
6 MotionValue,
7 SpringOptions,
8 useMotionValue,
9 useSpring,
10 useTransform,
11} from 'motion/react';
12
13const ImageComparisonContext = createContext<
14 | {
15 sliderPosition: number;
16 setSliderPosition: (pos: number) => void;
17 motionSliderPosition: MotionValue<number>;
18 }
19 | undefined
20>(undefined);
21
22export type ImageComparisonProps = {
23 children: React.ReactNode;
24 className?: string;
25 enableHover?: boolean;
26 springOptions?: SpringOptions;
27};
28
29const DEFAULT_SPRING_OPTIONS = {
30 bounce: 0,
31 duration: 0,
32};
33
34function ImageComparison({
35 children,
36 className,
37 enableHover,
38 springOptions,
39}: ImageComparisonProps) {
40 const [isDragging, setIsDragging] = useState(false);
41 const motionValue = useMotionValue(50);
42 const motionSliderPosition = useSpring(
43 motionValue,
44 springOptions ?? DEFAULT_SPRING_OPTIONS
45 );
46 const [sliderPosition, setSliderPosition] = useState(50);
47
48 const handleDrag = (event: React.MouseEvent | React.TouchEvent) => {
49 if (!isDragging && !enableHover) return;
50
51 const containerRect = (
52 event.currentTarget as HTMLElement
53 ).getBoundingClientRect();
54 const x =
55 'touches' in event
56 ? event.touches[0].clientX - containerRect.left
57 : (event as React.MouseEvent).clientX - containerRect.left;
58
59 const percentage = Math.min(
60 Math.max((x / containerRect.width) * 100, 0),
61 100
62 );
63 motionValue.set(percentage);
64 setSliderPosition(percentage);
65 };
66
67 return (
68 <ImageComparisonContext.Provider
69 value={{ sliderPosition, setSliderPosition, motionSliderPosition }}
70 >
71 <div
72 className={cn(
73 'relative select-none overflow-hidden',
74 enableHover && 'cursor-ew-resize',
75 className
76 )}
77 onMouseMove={handleDrag}
78 onMouseDown={() => !enableHover && setIsDragging(true)}
79 onMouseUp={() => !enableHover && setIsDragging(false)}
80 onMouseLeave={() => !enableHover && setIsDragging(false)}
81 onTouchMove={handleDrag}
82 onTouchStart={() => !enableHover && setIsDragging(true)}
83 onTouchEnd={() => !enableHover && setIsDragging(false)}
84 >
85 {children}
86 </div>
87 </ImageComparisonContext.Provider>
88 );
89}
90
91const ImageComparisonImage = ({
92 className,
93 alt,
94 src,
95 position,
96}: {
97 className?: string;
98 alt: string;
99 src: string;
100 position: 'left' | 'right';
101}) => {
102 const { motionSliderPosition } = useContext(ImageComparisonContext)!;
103 const leftClipPath = useTransform(
104 motionSliderPosition,
105 (value) => `inset(0 0 0 ${value}%)`
106 );
107 const rightClipPath = useTransform(
108 motionSliderPosition,
109 (value) => `inset(0 ${100 - value}% 0 0)`
110 );
111
112 return (
113 <motion.img
114 src={src}
115 alt={alt}
116 className={cn('absolute inset-0 h-full w-full object-cover', className)}
117 style={{
118 clipPath: position === 'left' ? leftClipPath : rightClipPath,
119 }}
120 />
121 );
122};
123
124const ImageComparisonSlider = ({
125 className,
126 children,
127}: {
128 className: string;
129 children?: React.ReactNode;
130}) => {
131 const { motionSliderPosition } = useContext(ImageComparisonContext)!;
132
133 const left = useTransform(motionSliderPosition, (value) => `${value}%`);
134
135 return (
136 <motion.div
137 className={cn('absolute bottom-0 top-0 w-1 cursor-ew-resize', className)}
138 style={{
139 left,
140 }}
141 >
142 {children}
143 </motion.div>
144 );
145};
146
147export { ImageComparison, ImageComparisonImage, ImageComparisonSlider };
148