A before/after comparison slider with a draggable divider and round grab handle that clips one image over the other, split left/right or top/bottom. Plays a short hint sweep on mount and ships with built-in CSS gradient landscape scenes so it works without any images.
Preview
Customize
11 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add image-compareWrites components/ui/image-compare.tsx and components/styles/image-compare.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { ImageCompare } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <ImageCompare />6 );7}Props
Accessibility
The handle is a focusable role="slider" (aria-valuemin 0, aria-valuemax 100, aria-valuenow, aria-orientation) named "Before / After comparison" with an aria-valuetext like "60% Before, 40% After". Arrow keys move by the step, PageUp/PageDown by 10, Home/End jump to the edges. Each side is exposed as an image with its label as alt text; the corner tags are aria-hidden duplicates. Pointer dragging works anywhere on the frame (touch keeps vertical page scrolling). Focus is shown with a 2px outline plus an accent glow. Under prefers-reduced-motion the intro sweep and handle scale are skipped.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import styles from "../styles/image-compare.module.css";5 6export interface ImageCompareProps {7 /** URL of the "before" image (defaults to a muted gradient scene) */8 beforeSrc?: string;9 /** URL of the "after" image (defaults to a vivid gradient scene) */10 afterSrc?: string;11 /** Label and alt text of the before side */12 beforeLabel?: string;13 /** Label and alt text of the after side */14 afterLabel?: string;15 /** Controlled divider position in percent (0–100) */16 value?: number;17 /** Initial divider position in percent when uncontrolled */18 defaultValue?: number;19 /** Called with the new position while dragging or using the keyboard */20 onValueChange?: (value: number) => void;21 /** Divider direction: horizontal splits left/right, vertical splits top/bottom */22 orientation?: "horizontal" | "vertical";23 /** Show the Before/After corner labels */24 showLabels?: boolean;25 /** Play a short hint sweep on mount */26 intro?: boolean;27 /** Divider and handle color */28 handleColor?: string;29 /** Color of the handle arrows and focus glow */30 accentColor?: string;31 /** Keyboard step in percent */32 step?: number;33 /** Height in px */34 height?: number;35 /** Corner radius in px */36 borderRadius?: number;37 /** Optional className merged onto the root */38 className?: string;39 /** Optional inline styles merged onto the root */40 style?: React.CSSProperties;41}42 43const clamp = (n: number) => Math.min(100, Math.max(0, n));44 45function Scene({ src, alt, vivid }: { src?: string; alt: string; vivid: boolean }) {46 if (src) return <img className={styles.img} src={src} alt={alt} draggable={false} />;47 return (48 <div className={styles.scene} data-vivid={vivid ? "" : undefined} role="img" aria-label={alt}>49 <span className={styles.sun} />50 <span className={`${styles.ridge} ${styles.ridgeBack}`} />51 <span className={`${styles.ridge} ${styles.ridgeMid}`} />52 <span className={`${styles.ridge} ${styles.ridgeFront}`} />53 <span className={styles.water} />54 </div>55 );56}57 58export function ImageCompare({59 beforeSrc,60 afterSrc,61 beforeLabel = "Before",62 afterLabel = "After",63 value: valueProp,64 defaultValue = 50,65 onValueChange,66 orientation = "horizontal",67 showLabels = true,68 intro = true,69 handleColor = "#ffffff",70 accentColor = "#6366f1",71 step = 2,72 height = 360,73 borderRadius = 12,74 className = "",75 style,76}: ImageCompareProps) {77 const [inner, setInner] = React.useState(clamp(defaultValue));78 const controlled = valueProp !== undefined;79 const pos = clamp(controlled ? valueProp : inner);80 const [wiggle, setWiggle] = React.useState(0);81 const [dragging, setDragging] = React.useState(false);82 const rootRef = React.useRef<HTMLDivElement>(null);83 const rafRef = React.useRef(0);84 const vertical = orientation === "vertical";85 86 React.useEffect(() => {87 if (!intro || typeof window === "undefined") return;88 if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;89 const start = performance.now();90 const tick = (now: number) => {91 const t = Math.min(1, (now - start) / 1600);92 setWiggle(t >= 1 ? 0 : 16 * Math.sin(t * Math.PI * 3) * (1 - t) * (1 - t));93 if (t < 1) rafRef.current = requestAnimationFrame(tick);94 };95 rafRef.current = requestAnimationFrame(tick);96 return () => cancelAnimationFrame(rafRef.current);97 }, [intro]);98 99 const set = (n: number) => {100 const next = Math.round(clamp(n) * 10) / 10;101 cancelAnimationFrame(rafRef.current);102 setWiggle(0);103 if (!controlled) setInner(next);104 onValueChange?.(next);105 };106 107 const fromPointer = (e: React.PointerEvent) => {108 const r = rootRef.current?.getBoundingClientRect();109 if (!r) return;110 set(vertical ? ((e.clientY - r.top) / r.height) * 100 : ((e.clientX - r.left) / r.width) * 100);111 };112 113 const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {114 const map: Record<string, number> = {115 ArrowLeft: -step,116 ArrowDown: vertical ? step : -step,117 ArrowRight: step,118 ArrowUp: vertical ? -step : step,119 PageDown: -10,120 PageUp: 10,121 };122 if (e.key === "Home") set(0);123 else if (e.key === "End") set(100);124 else if (e.key in map) set(pos + map[e.key]);125 else return;126 e.preventDefault();127 };128 129 const shown = clamp(pos + wiggle);130 const valueText = `${Math.round(shown)}% ${beforeLabel}, ${Math.round(100 - shown)}% ${afterLabel}`;131 132 return (133 <div134 ref={rootRef}135 className={`${styles.root} ${className}`.trim()}136 data-orientation={orientation}137 data-dragging={dragging ? "" : undefined}138 style={139 {140 "--mf-ic-pos": `${shown}%`,141 "--mf-ic-handle": handleColor,142 "--mf-ic-accent": accentColor,143 height,144 borderRadius,145 ...style,146 } as React.CSSProperties147 }148 onPointerDown={(e) => {149 if (e.button !== 0) return;150 e.currentTarget.setPointerCapture(e.pointerId);151 setDragging(true);152 fromPointer(e);153 }}154 onPointerMove={(e) => dragging && fromPointer(e)}155 onPointerUp={() => setDragging(false)}156 onPointerCancel={() => setDragging(false)}157 >158 <div className={styles.layer}>159 <Scene src={afterSrc} alt={afterLabel} vivid />160 </div>161 <div className={`${styles.layer} ${styles.before}`}>162 <Scene src={beforeSrc} alt={beforeLabel} vivid={false} />163 </div>164 {showLabels && (165 <>166 <span className={`${styles.tag} ${styles.tagBefore}`} data-hidden={shown < 14 ? "" : undefined} aria-hidden="true">167 {beforeLabel}168 </span>169 <span className={`${styles.tag} ${styles.tagAfter}`} data-hidden={shown > 86 ? "" : undefined} aria-hidden="true">170 {afterLabel}171 </span>172 </>173 )}174 <div className={styles.divider} aria-hidden="true" />175 <div176 role="slider"177 tabIndex={0}178 aria-label={`${beforeLabel} / ${afterLabel} comparison`}179 aria-orientation={orientation}180 aria-valuemin={0}181 aria-valuemax={100}182 aria-valuenow={Math.round(shown)}183 aria-valuetext={valueText}184 className={styles.handle}185 onKeyDown={onKeyDown}186 >187 <svg aria-hidden="true" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">188 <path d="m9 7-5 5 5 5M15 7l5 5-5 5" />189 </svg>190 </div>191 </div>192 );193}