Comparison
Interactive slider comparison component for React and Next.js applications. Built with TypeScript support, Tailwind CSS styling, and shadcn/ui design featuring drag controls, hover modes, and smooth animations.
'use client';import { Comparison, ComparisonHandle, ComparisonItem } from '@/components/ui/shadcn-io/comparison';import Image from 'next/image';const Example = () => ( <Comparison className="aspect-video"> <ComparisonItem className="bg-red-500" position="left"> <Image alt="Placeholder 1" className="opacity-50" height={1080} src="https://placehold.co/1920x1080?random=1" unoptimized width={1920} /> </ComparisonItem> <ComparisonItem className="bg-blue-500" position="right"> <Image alt="Placeholder 2" className="opacity-50" height={1440} src="https://placehold.co/2560x1440?random=2" unoptimized width={2560} /> </ComparisonItem> <ComparisonHandle /> </Comparison>);export default Example;
'use client';import { GripVerticalIcon } from 'lucide-react';import { type MotionValue, motion, useMotionValue, useSpring, useTransform,} from 'motion/react';import { type ComponentProps, createContext, type HTMLAttributes, type MouseEventHandler, type ReactNode, type TouchEventHandler, useContext, useState,} from 'react';import { cn } from '@/lib/utils';type ImageComparisonContextType = { sliderPosition: number; setSliderPosition: (pos: number) => void; motionSliderPosition: MotionValue<number>; mode: 'hover' | 'drag';};const ImageComparisonContext = createContext< ImageComparisonContextType | undefined>(undefined);const useImageComparisonContext = () => { const context = useContext(ImageComparisonContext); if (!context) { throw new Error( 'useImageComparisonContext must be used within a ImageComparison' ); } return context;};export type ComparisonProps = HTMLAttributes<HTMLDivElement> & { mode?: 'hover' | 'drag'; onDragStart?: () => void; onDragEnd?: () => void;};export const Comparison = ({ className, mode = 'drag', onDragStart, onDragEnd, ...props}: ComparisonProps) => { const [isDragging, setIsDragging] = useState(false); const motionValue = useMotionValue(50); const motionSliderPosition = useSpring(motionValue, { bounce: 0, duration: 0, }); const [sliderPosition, setSliderPosition] = useState(50); const handleDrag = (domRect: DOMRect, clientX: number) => { if (!isDragging && mode === 'drag') { return; } const x = clientX - domRect.left; const percentage = Math.min(Math.max((x / domRect.width) * 100, 0), 100); motionValue.set(percentage); setSliderPosition(percentage); }; const handleMouseDrag: MouseEventHandler<HTMLDivElement> = (event) => { if (!event) { return; } const containerRect = event.currentTarget.getBoundingClientRect(); handleDrag(containerRect, event.clientX); }; const handleTouchDrag: TouchEventHandler<HTMLDivElement> = (event) => { if (!event) { return; } const containerRect = event.currentTarget.getBoundingClientRect(); const touches = Array.from(event.touches); handleDrag(containerRect, touches.at(0)?.clientX ?? 0); }; const handleDragStart = () => { if (mode === 'drag') { setIsDragging(true); onDragStart?.(); } }; const handleDragEnd = () => { if (mode === 'drag') { setIsDragging(false); onDragEnd?.(); } }; return ( <ImageComparisonContext.Provider value={{ sliderPosition, setSliderPosition, motionSliderPosition, mode }} > <div aria-label="Comparison slider" aria-valuemax={100} aria-valuemin={0} aria-valuenow={sliderPosition} className={cn( 'relative isolate w-full select-none overflow-hidden', className )} onMouseDown={handleDragStart} onMouseLeave={handleDragEnd} onMouseMove={handleMouseDrag} onMouseUp={handleDragEnd} onTouchEnd={handleDragEnd} onTouchMove={handleTouchDrag} onTouchStart={handleDragStart} role="slider" tabIndex={0} {...props} /> </ImageComparisonContext.Provider> );};export type ComparisonItemProps = ComponentProps<typeof motion.div> & { position: 'left' | 'right';};export const ComparisonItem = ({ className, position, ...props}: ComparisonItemProps) => { const { motionSliderPosition } = useImageComparisonContext(); const leftClipPath = useTransform( motionSliderPosition, (value) => `inset(0 0 0 ${value}%)` ); const rightClipPath = useTransform( motionSliderPosition, (value) => `inset(0 ${100 - value}% 0 0)` ); return ( <motion.div aria-hidden="true" className={cn('absolute inset-0 h-full w-full object-cover', className)} role="img" style={{ clipPath: position === 'left' ? leftClipPath : rightClipPath, }} {...props} /> );};export type ComparisonHandleProps = ComponentProps<typeof motion.div> & { children?: ReactNode;};export const ComparisonHandle = ({ className, children, ...props}: ComparisonHandleProps) => { const { motionSliderPosition, mode } = useImageComparisonContext(); const left = useTransform(motionSliderPosition, (value) => `${value}%`); return ( <motion.div aria-hidden="true" className={cn( '-translate-x-1/2 absolute top-0 z-50 flex h-full w-10 items-center justify-center', mode === 'drag' && 'cursor-grab active:cursor-grabbing', className )} role="presentation" style={{ left }} {...props} > {children ?? ( <> <div className="-translate-x-1/2 absolute left-1/2 h-full w-1 bg-background" /> {mode === 'drag' && ( <div className="z-50 flex items-center justify-center rounded-sm bg-background px-0.5 py-1"> <GripVerticalIcon className="h-4 w-4 select-none text-muted-foreground" /> </div> )} </> )} </motion.div> );};
Installation
npx shadcn@latest add https://www.shadcn.io/registry/comparison.json
npx shadcn@latest add https://www.shadcn.io/registry/comparison.json
pnpm dlx shadcn@latest add https://www.shadcn.io/registry/comparison.json
bunx shadcn@latest add https://www.shadcn.io/registry/comparison.json
Features
- Draggable slider - Side-by-side comparison with interactive controls using Motion library animations
- Dual interaction modes - Both hover and drag functionality using JavaScript event handlers
- Touch support - Mobile-friendly interactions with gesture recognition for React applications
- Customizable appearance - Slider styling and positioning controls using Tailwind CSS utilities
- Responsive design - Works with any content size and container using TypeScript props
- Context provider - State management for comparison position using React Context
- Icon integration - Lucide icons with consistent styling using shadcn/ui theming
- Open source - Free comparison component with smooth animations and accessibility
Examples
Hover mode
'use client';import { Comparison, ComparisonHandle, ComparisonItem } from '@/components/ui/shadcn-io/comparison';import Image from 'next/image';const Example = () => ( <Comparison className="aspect-video" mode="hover"> <ComparisonItem className="bg-red-500" position="left"> <Image alt="Placeholder 1" className="opacity-50" height={1080} src="https://placehold.co/1920x1080?random=1" unoptimized width={1920} /> </ComparisonItem> <ComparisonItem className="bg-blue-500" position="right"> <Image alt="Placeholder 2" className="opacity-50" height={1440} src="https://placehold.co/2560x1440?random=2" unoptimized width={2560} /> </ComparisonItem> <ComparisonHandle /> </Comparison>);export default Example;
'use client';import { GripVerticalIcon } from 'lucide-react';import { type MotionValue, motion, useMotionValue, useSpring, useTransform,} from 'motion/react';import { type ComponentProps, createContext, type HTMLAttributes, type MouseEventHandler, type ReactNode, type TouchEventHandler, useContext, useState,} from 'react';import { cn } from '@/lib/utils';type ImageComparisonContextType = { sliderPosition: number; setSliderPosition: (pos: number) => void; motionSliderPosition: MotionValue<number>; mode: 'hover' | 'drag';};const ImageComparisonContext = createContext< ImageComparisonContextType | undefined>(undefined);const useImageComparisonContext = () => { const context = useContext(ImageComparisonContext); if (!context) { throw new Error( 'useImageComparisonContext must be used within a ImageComparison' ); } return context;};export type ComparisonProps = HTMLAttributes<HTMLDivElement> & { mode?: 'hover' | 'drag'; onDragStart?: () => void; onDragEnd?: () => void;};export const Comparison = ({ className, mode = 'drag', onDragStart, onDragEnd, ...props}: ComparisonProps) => { const [isDragging, setIsDragging] = useState(false); const motionValue = useMotionValue(50); const motionSliderPosition = useSpring(motionValue, { bounce: 0, duration: 0, }); const [sliderPosition, setSliderPosition] = useState(50); const handleDrag = (domRect: DOMRect, clientX: number) => { if (!isDragging && mode === 'drag') { return; } const x = clientX - domRect.left; const percentage = Math.min(Math.max((x / domRect.width) * 100, 0), 100); motionValue.set(percentage); setSliderPosition(percentage); }; const handleMouseDrag: MouseEventHandler<HTMLDivElement> = (event) => { if (!event) { return; } const containerRect = event.currentTarget.getBoundingClientRect(); handleDrag(containerRect, event.clientX); }; const handleTouchDrag: TouchEventHandler<HTMLDivElement> = (event) => { if (!event) { return; } const containerRect = event.currentTarget.getBoundingClientRect(); const touches = Array.from(event.touches); handleDrag(containerRect, touches.at(0)?.clientX ?? 0); }; const handleDragStart = () => { if (mode === 'drag') { setIsDragging(true); onDragStart?.(); } }; const handleDragEnd = () => { if (mode === 'drag') { setIsDragging(false); onDragEnd?.(); } }; return ( <ImageComparisonContext.Provider value={{ sliderPosition, setSliderPosition, motionSliderPosition, mode }} > <div aria-label="Comparison slider" aria-valuemax={100} aria-valuemin={0} aria-valuenow={sliderPosition} className={cn( 'relative isolate w-full select-none overflow-hidden', className )} onMouseDown={handleDragStart} onMouseLeave={handleDragEnd} onMouseMove={handleMouseDrag} onMouseUp={handleDragEnd} onTouchEnd={handleDragEnd} onTouchMove={handleTouchDrag} onTouchStart={handleDragStart} role="slider" tabIndex={0} {...props} /> </ImageComparisonContext.Provider> );};export type ComparisonItemProps = ComponentProps<typeof motion.div> & { position: 'left' | 'right';};export const ComparisonItem = ({ className, position, ...props}: ComparisonItemProps) => { const { motionSliderPosition } = useImageComparisonContext(); const leftClipPath = useTransform( motionSliderPosition, (value) => `inset(0 0 0 ${value}%)` ); const rightClipPath = useTransform( motionSliderPosition, (value) => `inset(0 ${100 - value}% 0 0)` ); return ( <motion.div aria-hidden="true" className={cn('absolute inset-0 h-full w-full object-cover', className)} role="img" style={{ clipPath: position === 'left' ? leftClipPath : rightClipPath, }} {...props} /> );};export type ComparisonHandleProps = ComponentProps<typeof motion.div> & { children?: ReactNode;};export const ComparisonHandle = ({ className, children, ...props}: ComparisonHandleProps) => { const { motionSliderPosition, mode } = useImageComparisonContext(); const left = useTransform(motionSliderPosition, (value) => `${value}%`); return ( <motion.div aria-hidden="true" className={cn( '-translate-x-1/2 absolute top-0 z-50 flex h-full w-10 items-center justify-center', mode === 'drag' && 'cursor-grab active:cursor-grabbing', className )} role="presentation" style={{ left }} {...props} > {children ?? ( <> <div className="-translate-x-1/2 absolute left-1/2 h-full w-1 bg-background" /> {mode === 'drag' && ( <div className="z-50 flex items-center justify-center rounded-sm bg-background px-0.5 py-1"> <GripVerticalIcon className="h-4 w-4 select-none text-muted-foreground" /> </div> )} </> )} </motion.div> );};
With event handlers
'use client';import { Comparison, ComparisonHandle, ComparisonItem } from '@/components/ui/shadcn-io/comparison';import Image from 'next/image';const Example = () => ( <Comparison className="aspect-video" onDragEnd={() => console.log('drag end')} onDragStart={() => console.log('drag start')} > <ComparisonItem className="bg-red-500" position="left"> <Image alt="Placeholder 1" className="opacity-50" height={1080} src="https://placehold.co/1920x1080?random=1" unoptimized width={1920} /> </ComparisonItem> <ComparisonItem className="bg-blue-500" position="right"> <Image alt="Placeholder 2" className="opacity-50" height={1440} src="https://placehold.co/2560x1440?random=2" unoptimized width={2560} /> </ComparisonItem> <ComparisonHandle /> </Comparison>);export default Example;
'use client';import { GripVerticalIcon } from 'lucide-react';import { type MotionValue, motion, useMotionValue, useSpring, useTransform,} from 'motion/react';import { type ComponentProps, createContext, type HTMLAttributes, type MouseEventHandler, type ReactNode, type TouchEventHandler, useContext, useState,} from 'react';import { cn } from '@/lib/utils';type ImageComparisonContextType = { sliderPosition: number; setSliderPosition: (pos: number) => void; motionSliderPosition: MotionValue<number>; mode: 'hover' | 'drag';};const ImageComparisonContext = createContext< ImageComparisonContextType | undefined>(undefined);const useImageComparisonContext = () => { const context = useContext(ImageComparisonContext); if (!context) { throw new Error( 'useImageComparisonContext must be used within a ImageComparison' ); } return context;};export type ComparisonProps = HTMLAttributes<HTMLDivElement> & { mode?: 'hover' | 'drag'; onDragStart?: () => void; onDragEnd?: () => void;};export const Comparison = ({ className, mode = 'drag', onDragStart, onDragEnd, ...props}: ComparisonProps) => { const [isDragging, setIsDragging] = useState(false); const motionValue = useMotionValue(50); const motionSliderPosition = useSpring(motionValue, { bounce: 0, duration: 0, }); const [sliderPosition, setSliderPosition] = useState(50); const handleDrag = (domRect: DOMRect, clientX: number) => { if (!isDragging && mode === 'drag') { return; } const x = clientX - domRect.left; const percentage = Math.min(Math.max((x / domRect.width) * 100, 0), 100); motionValue.set(percentage); setSliderPosition(percentage); }; const handleMouseDrag: MouseEventHandler<HTMLDivElement> = (event) => { if (!event) { return; } const containerRect = event.currentTarget.getBoundingClientRect(); handleDrag(containerRect, event.clientX); }; const handleTouchDrag: TouchEventHandler<HTMLDivElement> = (event) => { if (!event) { return; } const containerRect = event.currentTarget.getBoundingClientRect(); const touches = Array.from(event.touches); handleDrag(containerRect, touches.at(0)?.clientX ?? 0); }; const handleDragStart = () => { if (mode === 'drag') { setIsDragging(true); onDragStart?.(); } }; const handleDragEnd = () => { if (mode === 'drag') { setIsDragging(false); onDragEnd?.(); } }; return ( <ImageComparisonContext.Provider value={{ sliderPosition, setSliderPosition, motionSliderPosition, mode }} > <div aria-label="Comparison slider" aria-valuemax={100} aria-valuemin={0} aria-valuenow={sliderPosition} className={cn( 'relative isolate w-full select-none overflow-hidden', className )} onMouseDown={handleDragStart} onMouseLeave={handleDragEnd} onMouseMove={handleMouseDrag} onMouseUp={handleDragEnd} onTouchEnd={handleDragEnd} onTouchMove={handleTouchDrag} onTouchStart={handleDragStart} role="slider" tabIndex={0} {...props} /> </ImageComparisonContext.Provider> );};export type ComparisonItemProps = ComponentProps<typeof motion.div> & { position: 'left' | 'right';};export const ComparisonItem = ({ className, position, ...props}: ComparisonItemProps) => { const { motionSliderPosition } = useImageComparisonContext(); const leftClipPath = useTransform( motionSliderPosition, (value) => `inset(0 0 0 ${value}%)` ); const rightClipPath = useTransform( motionSliderPosition, (value) => `inset(0 ${100 - value}% 0 0)` ); return ( <motion.div aria-hidden="true" className={cn('absolute inset-0 h-full w-full object-cover', className)} role="img" style={{ clipPath: position === 'left' ? leftClipPath : rightClipPath, }} {...props} /> );};export type ComparisonHandleProps = ComponentProps<typeof motion.div> & { children?: ReactNode;};export const ComparisonHandle = ({ className, children, ...props}: ComparisonHandleProps) => { const { motionSliderPosition, mode } = useImageComparisonContext(); const left = useTransform(motionSliderPosition, (value) => `${value}%`); return ( <motion.div aria-hidden="true" className={cn( '-translate-x-1/2 absolute top-0 z-50 flex h-full w-10 items-center justify-center', mode === 'drag' && 'cursor-grab active:cursor-grabbing', className )} role="presentation" style={{ left }} {...props} > {children ?? ( <> <div className="-translate-x-1/2 absolute left-1/2 h-full w-1 bg-background" /> {mode === 'drag' && ( <div className="z-50 flex items-center justify-center rounded-sm bg-background px-0.5 py-1"> <GripVerticalIcon className="h-4 w-4 select-none text-muted-foreground" /> </div> )} </> )} </motion.div> );};
Use Cases
- Before/after displays - Image comparisons for design and photo editing
- Product comparisons - Feature differences and visual changes
- A/B testing - Interface variations and design experiments
- Progress tracking - Before and after states in dashboards
Implementation
Built with Motion for smooth animations. Uses context for state sharing. Supports touch and mouse events. Customizable slider with responsive overlay design.
Relative Time
Multi-timezone time display component for React and Next.js applications. Built with TypeScript support, Tailwind CSS styling, and shadcn/ui design featuring auto-updates, custom formatting, and controlled time states.
kbd
Keyboard shortcut display component for React and Next.js applications. Built with TypeScript support, Tailwind CSS styling, and shadcn/ui design featuring ARIA labels, custom separators, and modifier key support.