{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "image-compare",
  "title": "Image Compare",
  "description": "Compare any two layers (screenshots, themes, a plain component and its polished twin) with one divider. A flick coasts to 0, 50 or 100, a slow release stays where it lands, the tags fade as the line passes under them, and the knob is a keyboard slider.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://eased.dev/r/eased-tokens.json",
    "https://eased.dev/r/eased-motion.json",
    "https://eased.dev/r/eased-gesture.json",
    "https://eased.dev/r/eased-inertia-core.json"
  ],
  "files": [
    {
      "path": "src/registry/eased/image-compare/image-compare.tsx",
      "content": "\"use client\";\n\nimport { animate, motion, useMotionValue, useMotionValueEvent, useTransform, type MotionValue } from \"motion/react\";\nimport {\n  useCallback,\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type KeyboardEvent as ReactKeyboardEvent,\n  type MouseEvent as ReactMouseEvent,\n  type PointerEvent as ReactPointerEvent,\n  type ReactNode,\n} from \"react\";\nimport { useInertia } from \"../_primitives/inertia-core/use-inertia\";\nimport { useDrag } from \"../_primitives/gesture/use-drag\";\nimport { clamp } from \"../_primitives/gesture/gesture-math\";\nimport { snappy, useSafeReducedMotion } from \"../lib/motion\";\n\nexport type ImageCompareProps = {\n  /** The first layer: left of the divider (or above it when vertical). Any element: an image, a component, a whole theme. */\n  before: ReactNode;\n  /** The second layer: right of the divider (or below it). It sets the frame's size unless you size the root. */\n  after: ReactNode;\n  /** Tag over the first layer. `null` hides it. Default \"Before\". */\n  beforeLabel?: ReactNode;\n  /** Tag over the second layer. `null` hides it. Default \"After\". */\n  afterLabel?: ReactNode;\n  /** Divider position, 0–100 (% of the frame from the start edge). Controlled. */\n  value?: number;\n  /** Uncontrolled starting position. Default 50. */\n  defaultValue?: number;\n  /** Called with the position (0–100) whenever the divider comes to rest. */\n  onValueChange?: (value: number) => void;\n  /** Where a flick may rest, in %. `false` lets every release coast freely. Default `[0, 50, 100]`. */\n  snap?: readonly number[] | false;\n  /** `horizontal` (default) splits left and right; `vertical` splits top and bottom. */\n  orientation?: \"horizontal\" | \"vertical\";\n  /** Let pointer and keyboard reach the layers' content. Default false: both layers are inert pictures. */\n  interactive?: boolean;\n  /** Names the slider, e.g. \"Compare light and dark theme\". Default \"Compare\". */\n  \"aria-label\"?: string;\n  /** Appended to the root's classes. Size the root here when neither layer has an intrinsic size. */\n  className?: string;\n};\n\n/** Below this release speed (px/s) a drag rests where it was let go instead of coasting. */\nconst FLICK = 380;\n/** A slow release within this share of the frame lands on the snap point. */\nconst MAGNET = 0.035;\n/** Knob radius plus a breath, so the knob never clips at the ends while the line runs on. */\nconst KNOB_INSET = 22;\n/** Pointer-follow for the hover lean: a 2% pull toward the pointer at most. */\nconst LEAN_MAX = 2;\nconst LEAN_GAIN = 0.06;\n/** How far (px) a label fades over as the divider reaches it. */\nconst LABEL_FEATHER = 28;\n/** Labels' inset from the frame edge, matching `top-3 left-3` below. */\nconst LABEL_INSET = 12;\n\nconst FINE = \"(hover: hover) and (pointer: fine)\";\n\n/**\n * Two layers of anything, one divider between them. The top layer is cut with `clip-path`\n * from a MotionValue, so dragging never re-renders; a flick coasts to the nearest snap point\n * (0, 50, 100), a slow release rests where it was let go, and the tags fade as the line\n * passes under them. The knob is a keyboard slider.\n */\nexport function ImageCompare({\n  before,\n  after,\n  beforeLabel = \"Before\",\n  afterLabel = \"After\",\n  value: controlled,\n  defaultValue = 50,\n  onValueChange,\n  snap = [0, 50, 100],\n  orientation = \"horizontal\",\n  interactive = false,\n  \"aria-label\": ariaLabel = \"Compare\",\n  className = \"\",\n}: ImageCompareProps) {\n  const horizontal = orientation === \"horizontal\";\n  const axis = horizontal ? \"x\" : \"y\";\n  const reduced = useSafeReducedMotion();\n  const start = clamp(controlled ?? defaultValue, 0, 100);\n\n  const root = useRef<HTMLDivElement | null>(null);\n  const knob = useRef<HTMLDivElement | null>(null);\n  const beforeTag = useRef<HTMLSpanElement | null>(null);\n  const afterTag = useRef<HTMLSpanElement | null>(null);\n\n  // The frame's length on the axis, and the tags' extents, measured (0 until mounted).\n  const [size, setSize] = useState(0);\n  const sizeMv = useMotionValue(0);\n  const tagsMv = useMotionValue({ before: 0, after: 0 });\n\n  // px drives the gesture; pct is what everything draws from (SSR-safe before measuring).\n  const px = useMotionValue(0);\n  const pct = useMotionValue(start);\n  const lean = useMotionValue(0);\n  const shown = useTransform(() => pct.get() + lean.get());\n  const resting = useRef(start);\n  const aim = useRef<number | null>(null);\n\n  const [dragging, setDragging] = useState(false);\n\n  const snapPx = snap === false ? undefined : snap.map((p) => (clamp(p, 0, 100) / 100) * size);\n\n  const commit = useCallback(\n    (v: number) => {\n      const next = Math.round(v * 10) / 10;\n      if (next === resting.current) return;\n      resting.current = next;\n      onValueChange?.(next);\n    },\n    [onValueChange],\n  );\n\n  const inertia = useInertia(px, {\n    snap: snapPx,\n    min: 0,\n    max: size,\n    minVelocity: FLICK,\n    magnet: size * MAGNET,\n    onRest: (v) => commit(size ? (v / size) * 100 : resting.current),\n  });\n\n  // px → pct, every frame of a drag or coast.\n  useMotionValueEvent(px, \"change\", (v) => {\n    const s = sizeMv.get();\n    if (s > 0) pct.set((v / s) * 100);\n  });\n\n  // aria-valuenow and valuetext follow the line without re-rendering.\n  useMotionValueEvent(pct, \"change\", (v) => {\n    const el = knob.current;\n    if (!el) return;\n    const p = Math.round(clamp(v, 0, 100));\n    el.setAttribute(\"aria-valuenow\", String(p));\n    el.setAttribute(\"aria-valuetext\", valueText(p, beforeLabel, afterLabel));\n  });\n\n  // Measure the frame and the tags; a resize keeps the divider at the same percentage.\n  useLayoutEffect(() => {\n    const el = root.current;\n    if (!el) return;\n    const measure = () => {\n      const s = horizontal ? el.clientWidth : el.clientHeight;\n      const tag = (t: HTMLElement | null) => (t ? (horizontal ? t.offsetWidth : t.offsetHeight) : 0);\n      tagsMv.set({ before: tag(beforeTag.current), after: tag(afterTag.current) });\n      if (s === sizeMv.get()) return;\n      const p = pct.get();\n      sizeMv.set(s);\n      setSize(s);\n      inertia.stop();\n      px.jump((clamp(p, 0, 100) / 100) * s);\n    };\n    measure();\n    const ro = new ResizeObserver(measure);\n    ro.observe(el);\n    if (beforeTag.current) ro.observe(beforeTag.current);\n    if (afterTag.current) ro.observe(afterTag.current);\n    return () => ro.disconnect();\n  }, [horizontal, inertia, pct, px, sizeMv, tagsMv]);\n\n  // A controlled value that moves on its own glides there.\n  useEffect(() => {\n    if (controlled === undefined) return;\n    const target = clamp(controlled, 0, 100);\n    resting.current = target;\n    const s = sizeMv.get();\n    if (!s) {\n      pct.set(target);\n      return;\n    }\n    if (Math.abs((px.get() / s) * 100 - target) < 0.05) return;\n    inertia.to((target / 100) * s, { transition: { type: \"spring\", visualDuration: 0.5, bounce: 0 } });\n  }, [controlled, inertia, pct, px, sizeMv]);\n\n  const { ref: dragRef } = useDrag({\n    axis,\n    value: px,\n    min: 0,\n    max: size,\n    dimension: 24,\n    threshold: 3,\n    scrollHandoff: false,\n    disabled: size === 0,\n    onDragStart: () => {\n      inertia.stop();\n      setDragging(true);\n      animate(lean, 0, reduced ? { duration: 0 } : snappy);\n    },\n    onRelease: ({ velocity }) => {\n      setDragging(false);\n      inertia.release(velocity);\n      return false;\n    },\n  });\n\n  const setRoot = useCallback(\n    (el: HTMLDivElement | null) => {\n      root.current = el;\n      return dragRef(el);\n    },\n    [dragRef],\n  );\n\n  const pointerPct = (e: { clientX: number; clientY: number }) => {\n    const r = root.current?.getBoundingClientRect();\n    if (!r) return null;\n    return clamp(horizontal ? ((e.clientX - r.left) / r.width) * 100 : ((e.clientY - r.top) / r.height) * 100, 0, 100);\n  };\n\n  // A finger that lands mid-coast catches the line where it is.\n  const onPointerDownCapture = () => {\n    if (inertia.isAnimating()) inertia.stop();\n  };\n\n  // A tap on the frame sends the line there (a drag never also clicks).\n  const onClick = (e: ReactMouseEvent) => {\n    if (!size || (knob.current && knob.current.contains(e.target as Node))) return;\n    const p = pointerPct(e);\n    if (p === null) return;\n    inertia.to((p / 100) * size, { transition: { type: \"spring\", visualDuration: 0.42, bounce: 0.1 } });\n  };\n\n  // Under a fine pointer, the line leans a little toward it before a drag starts.\n  const onPointerMove = (e: ReactPointerEvent) => {\n    if (reduced || dragging || e.pointerType !== \"mouse\" || !window.matchMedia(FINE).matches) return;\n    const p = pointerPct(e);\n    if (p === null) return;\n    const d = p - pct.get();\n    animate(lean, Math.sign(d) * Math.min(Math.abs(d) * LEAN_GAIN, LEAN_MAX), snappy);\n  };\n  const onPointerLeave = () => {\n    animate(lean, 0, reduced ? { duration: 0 } : snappy);\n  };\n\n  const onKeyDown = (e: ReactKeyboardEvent) => {\n    const big = e.shiftKey ? 10 : 1;\n    const back = horizontal ? \"ArrowLeft\" : \"ArrowUp\";\n    const fwd = horizontal ? \"ArrowRight\" : \"ArrowDown\";\n    // Held or repeated keys step from where the line is heading, not where it is mid-glide.\n    const here = size ? (px.get() / size) * 100 : pct.get();\n    const now = clamp(Math.round(inertia.isAnimating() && aim.current !== null ? aim.current : here), 0, 100);\n    const next =\n      e.key === back || (horizontal ? e.key === \"ArrowDown\" : e.key === \"ArrowLeft\") ? now - big\n      : e.key === fwd || (horizontal ? e.key === \"ArrowUp\" : e.key === \"ArrowRight\") ? now + big\n      : e.key === \"PageDown\" ? now - 10\n      : e.key === \"PageUp\" ? now + 10\n      : e.key === \"Home\" ? 0\n      : e.key === \"End\" ? 100\n      : undefined;\n    if (next === undefined) return;\n    e.preventDefault();\n    const target = clamp(next, 0, 100);\n    if (!size) return;\n    aim.current = target;\n    inertia.to((target / 100) * size, { transition: snappy });\n  };\n\n  const clip = useTransform(() => {\n    const cut = 100 - clamp(shown.get(), 0, 100);\n    return horizontal ? `inset(0 ${cut}% 0 0)` : `inset(0 0 ${cut}% 0)`;\n  });\n  const lineShift = useTransform(() => `${shown.get()}%`);\n  // The knob rides the line but stops short of the ends so it never clips.\n  const knobShift = useTransform(() => {\n    const s = sizeMv.get();\n    if (!s) return `${shown.get()}%`;\n    return `${clamp((shown.get() / 100) * s, KNOB_INSET, s - KNOB_INSET)}px`;\n  });\n\n  // Each tag fades out as the line approaches it, and back as the line moves on.\n  const beforeOpacity = useTagOpacity(shown, sizeMv, tagsMv, \"before\");\n  const afterOpacity = useTagOpacity(shown, sizeMv, tagsMv, \"after\");\n\n  const startPct = Math.round(start);\n  const layer = \"[grid-area:1/1] min-w-0 min-h-0\";\n  const tag =\n    \"pointer-events-none absolute inline-flex h-6 items-center rounded-control-xs bg-surface-raised/85 px-2 text-ui-xs font-medium text-text shadow-resting backdrop-blur-sm\";\n\n  return (\n    <div\n      ref={setRoot}\n      onPointerDownCapture={onPointerDownCapture}\n      onPointerMove={onPointerMove}\n      onPointerLeave={onPointerLeave}\n      onClick={onClick}\n      data-orientation={orientation}\n      data-dragging={dragging || undefined}\n      className={`relative grid min-w-0 overflow-hidden select-none ${horizontal ? \"cursor-ew-resize\" : \"cursor-ns-resize\"} ${className}`}\n    >\n      <div className={layer} inert={!interactive}>\n        {after}\n      </div>\n      <motion.div className={`${layer} relative`} style={{ clipPath: clip }} inert={!interactive}>\n        {before}\n      </motion.div>\n\n      {beforeLabel !== null && (\n        <motion.span ref={beforeTag} aria-hidden className={`${tag} top-3 left-3`} style={{ opacity: beforeOpacity }}>\n          {beforeLabel}\n        </motion.span>\n      )}\n      {afterLabel !== null && (\n        <motion.span\n          ref={afterTag}\n          aria-hidden\n          className={`${tag} ${horizontal ? \"top-3 right-3\" : \"bottom-3 left-3\"}`}\n          style={{ opacity: afterOpacity }}\n        >\n          {afterLabel}\n        </motion.span>\n      )}\n\n      {/* The line: a full-frame layer translated by the position, so % is of the frame. */}\n      <motion.div\n        aria-hidden\n        className=\"pointer-events-none absolute inset-0\"\n        style={horizontal ? { x: lineShift } : { y: lineShift }}\n      >\n        <div\n          className={`absolute bg-(--compare-line,var(--eased-surface-raised)) shadow-thumb ${\n            horizontal ? \"inset-y-0 left-0 w-0.5 -translate-x-1/2\" : \"inset-x-0 top-0 h-0.5 -translate-y-1/2\"\n          }`}\n        />\n      </motion.div>\n\n      <motion.div\n        className={`pointer-events-none absolute ${horizontal ? \"inset-y-0 left-0\" : \"inset-x-0 top-0\"}`}\n        style={horizontal ? { x: knobShift } : { y: knobShift }}\n      >\n        <motion.div\n          ref={knob}\n          role=\"slider\"\n          tabIndex={0}\n          aria-label={ariaLabel}\n          aria-orientation={orientation}\n          aria-valuemin={0}\n          aria-valuemax={100}\n          aria-valuenow={startPct}\n          aria-valuetext={valueText(startPct, beforeLabel, afterLabel)}\n          onKeyDown={onKeyDown}\n          animate={{ scale: dragging && !reduced ? 0.92 : 1 }}\n          transition={snappy}\n          className={`pointer-events-auto absolute flex size-9 items-center justify-center rounded-pill border border-line bg-surface-raised text-text-secondary shadow-floating focus-ring ${\n            horizontal ? \"top-1/2 -mt-[18px] -ml-[18px]\" : \"left-1/2 -mt-[18px] -ml-[18px]\"\n          } ${dragging ? \"cursor-grabbing\" : horizontal ? \"cursor-ew-resize\" : \"cursor-ns-resize\"}`}\n        >\n          <svg\n            viewBox=\"0 0 16 16\"\n            className={`size-4 ${horizontal ? \"\" : \"rotate-90\"}`}\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth=\"1.5\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            aria-hidden\n          >\n            <path d=\"M5.5 4.5 2 8l3.5 3.5M10.5 4.5 14 8l-3.5 3.5\" />\n          </svg>\n        </motion.div>\n      </motion.div>\n    </div>\n  );\n}\n\nfunction plain(label: ReactNode, fallback: string) {\n  return typeof label === \"string\" || typeof label === \"number\" ? String(label) : fallback;\n}\n\nfunction valueText(p: number, beforeLabel: ReactNode, afterLabel: ReactNode) {\n  return `${p}% ${plain(beforeLabel, \"before\")}, ${100 - p}% ${plain(afterLabel, \"after\")}`;\n}\n\nfunction useTagOpacity(\n  shown: MotionValue<number>,\n  size: MotionValue<number>,\n  tags: MotionValue<{ before: number; after: number }>,\n  which: \"before\" | \"after\",\n) {\n  return useTransform(() => {\n    const s = size.get();\n    if (!s) return 1;\n    const line = (shown.get() / 100) * s;\n    const extent = tags.get()[which];\n    // The before tag sits at the start edge: it hides as the line comes back over it.\n    // The after tag sits at the end edge: it hides as the line moves out over it.\n    const gap = which === \"before\" ? line - (LABEL_INSET + extent) : s - LABEL_INSET - extent - line;\n    return clamp(gap / LABEL_FEATHER, 0, 1);\n  });\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/image-compare/image-compare.tsx"
    }
  ],
  "meta": {
    "industries": [
      "saas",
      "devtools",
      "media",
      "real-estate",
      "ecommerce",
      "education"
    ],
    "releaseAt": "2026-10-20"
  },
  "docs": "Import ImageCompare from components/eased/image-compare/image-compare. The Eased tokens were merged into your global CSS; nothing to import. Dark mode follows .dark or data-theme=\"dark\".",
  "type": "registry:ui"
}