UIData Displayno dependencies

ImageCompare

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 props
50
2
360
12

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add image-compare

Writes 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.

example.tsx
1import { ImageCompare } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <ImageCompare />6  );7}

Props

PropTypeDefault
beforeSrc

URL of the before image; defaults to a desaturated gradient landscape.

stringundefined
afterSrc

URL of the after image; defaults to the same landscape in full color.

stringundefined
beforeLabel

Corner label and alt text of the before side.

string'Before'
afterLabel

Corner label and alt text of the after side.

string'After'
value

Controlled divider position in percent (0–100).

numberundefined
defaultValue

Initial divider position in percent when uncontrolled.

number50
onValueChange

Called with the new position while dragging or using the keyboard.

(value: number) => voidundefined
orientation

Split left/right with a vertical divider, or top/bottom with a horizontal one.

'horizontal' | 'vertical''horizontal'
showLabels

Show the Before/After corner tags (each fades out as the divider approaches it).

booleantrue
intro

Play a short decaying sweep on mount to hint that the divider is draggable.

booleantrue
handleColor

Divider line and handle color.

string'#ffffff'
accentColor

Handle arrow color and focus glow.

string'#6366f1'
step

Arrow-key step in percent.

number2
height

Height in px.

number360
borderRadius

Corner radius in px.

number12
className

Additional class merged onto the root element.

string''
style

Inline styles merged onto the root element.

React.CSSPropertiesundefined

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

components/ui/image-compare.tsx
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}