{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "image-compare",
  "type": "registry:ui",
  "title": "Image compare",
  "description": "Drag a divider across two images to see what changed.",
  "categories": [
    "data"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/image-compare/image-compare.tsx",
      "content": "\"use client\";\n\nimport {\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type KeyboardEvent,\n  type PointerEvent,\n  type ReactNode,\n} from \"react\";\nimport { animate, motion, useMotionValue, useReducedMotion, useTransform } from \"motion/react\";\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\n\nexport interface ImageCompareProps {\n  /** The original, shown on the left (or top). Pass an image with its own alt text; it is sized to cover the frame. */\n  before: ReactNode;\n  /** The result, shown on the right (or bottom). */\n  after: ReactNode;\n  /** Where the divider sits, in percent from the left (or top). */\n  position?: number;\n  defaultPosition?: number;\n  onPositionChange?: (position: number) => void;\n  /** Vertical stacks the images top and bottom. Changing it swings the divider a quarter turn instead of swapping layouts. */\n  orientation?: \"horizontal\" | \"vertical\";\n  /** Captions over each side. They fade as the divider reaches them. Pass false to hide them. */\n  labels?: [string, string] | false;\n  /** Accessible name of the divider. */\n  label?: string;\n  /** Frame proportions, as a CSS aspect-ratio. */\n  aspectRatio?: string;\n  className?: string;\n}\n\nconst layer =\n  \"absolute inset-0 [&_:where(img,picture,video)]:pointer-events-none [&_:where(img,picture,video)]:block [&_:where(img,picture,video)]:size-full [&_:where(img,picture,video)]:object-cover [&_:where(img,picture,video)]:[-webkit-user-drag:none]\";\n/** Captions sit on a frosted chip so they read over any photo, below the divider. */\nconst chip =\n  \"pointer-events-none absolute z-1 rounded-full bg-[color-mix(in_oklab,var(--page)_74%,transparent)] px-2.5 py-[3px] text-xs leading-body font-medium tracking-body text-ink backdrop-blur-[12px] backdrop-saturate-[1.4]\";\n\ntype Metrics = { w: number; h: number; bw: number; bh: number; aw: number; ah: number };\ntype Drag = { pointer: number; grab: number; press: boolean; started: boolean; x: number; y: number; raw: number };\n\nconst { spring } = motionTokens;\n/** Motion drops velocity on time-defined springs, so hand-offs run the same springs written as stiffness and damping, settling far below a pixel. */\nconst physical = ({ visualDuration, bounce }: { visualDuration: number; bounce: number }) => {\n  const root = (2 * Math.PI) / (visualDuration * 1.2);\n  return {\n    type: \"spring\" as const,\n    stiffness: root * root,\n    damping: 2 * (1 - bounce) * root,\n    restDelta: 0.002,\n    restSpeed: 0.02,\n  };\n};\nconst moveSpring = physical(spring.snappy);\nconst turnSpring = physical(spring.morph);\nconst kickSpring = physical(spring.morph);\n/** Rubber-band travel past an edge, the handle's inset from the frame, how far it gives inside that inset, and a key press at a limit. */\nconst STRETCH = 9,\n  MARGIN = 42,\n  GIVE = 12,\n  BUMP_PX = 150,\n  INSET = 12;\nconst clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));\nconst lerp = (from: number, to: number, amount: number) => from + (to - from) * amount;\n/** iOS style resistance: travel past a limit gives less and less, and never more than `limit` pixels. */\nconst rubber = (distance: number, limit = STRETCH) =>\n  Math.sign(distance) * (1 - 1 / ((Math.abs(distance) * 0.55) / limit + 1)) * limit;\n/** The handle keeps the line's pace in the middle and slows smoothly near an edge, so it is never cut off and never stops dead. */\nconst give = (distance: number) => GIVE * (1 - 1 / (distance / GIVE + 1));\nconst soften = (at: number, size: number) =>\n  at < MARGIN ? MARGIN - give(MARGIN - at) : at > size - MARGIN ? size - MARGIN + give(at - size + MARGIN) : at;\nconst fade = (room: number) => clamp(room / 28, 0, 1);\n\n/** The before side of a line through `center` at `angle`, cut from the frame: the clip while the divider turns. */\nfunction halfPlane(w: number, h: number, center: [number, number], angle: number) {\n  const nx = -Math.cos(angle),\n    ny = -Math.sin(angle);\n  const side = ([x, y]: [number, number]) => (x - center[0]) * nx + (y - center[1]) * ny;\n  const corners: [number, number][] = [\n    [0, 0],\n    [w, 0],\n    [w, h],\n    [0, h],\n  ];\n  const points: [number, number][] = [];\n  corners.forEach((corner, index) => {\n    const next = corners[(index + 1) % 4]!,\n      a = side(corner),\n      b = side(next);\n    if (a >= 0) points.push(corner);\n    if (a >= 0 !== b >= 0) {\n      const t = a / (a - b);\n      points.push([corner[0] + (next[0] - corner[0]) * t, corner[1] + (next[1] - corner[1]) * t]);\n    }\n  });\n  return points.length\n    ? `polygon(${points.map(([x, y]) => `${x.toFixed(2)}px ${y.toFixed(2)}px`).join(\", \")})`\n    : \"inset(50%)\";\n}\n\n/**\n * Before and after, split by a divider. Drag anywhere to move it: the handle follows 1:1, rubber-bands past the edges, and stays where it\n * is released. A press on the image springs the divider there; on touch the press waits to see a sideways drag so the page still scrolls.\n * Arrow keys step 1% (Shift for 10%), Home and End reveal one side fully, and a double-click on the handle springs it back to the middle.\n */\nexport function ImageCompare({\n  before,\n  after,\n  position,\n  defaultPosition = 50,\n  onPositionChange,\n  orientation = \"horizontal\",\n  labels = [\"Before\", \"After\"],\n  label = \"Before and after\",\n  aspectRatio = \"3 / 2\",\n  className,\n}: ImageCompareProps) {\n  const reduced = useReducedMotion();\n  const vertical = orientation === \"vertical\";\n  const [internal, setInternal] = useState(defaultPosition);\n  const current = clamp(position ?? internal, 0, 100);\n  const afterShare = Math.round(100 - current);\n  const rootRef = useRef<HTMLDivElement>(null);\n  const handleRef = useRef<HTMLDivElement>(null);\n  const beforeChip = useRef<HTMLSpanElement>(null);\n  const afterChip = useRef<HTMLSpanElement>(null);\n  const latest = useRef(current);\n  const goal = useRef(current);\n  const drag = useRef<Drag | null>(null);\n  const pressedHandle = useRef(false);\n  const [dragging, setDragging] = useState(false);\n  const [quiet, setQuiet] = useState(false);\n\n  // The divider is a committed position plus a catch-up offset: a press springs the offset to zero while the pointer moves the position\n  // 1:1, so the divider glides to the finger and never trails it.\n  const pos = useMotionValue(current),\n    lag = useMotionValue(0);\n  const theta = useMotionValue(vertical ? 90 : 0);\n  const metrics = useMotionValue<Metrics>({ w: 0, h: 0, bw: 0, bh: 0, aw: 0, ah: 0 });\n  const shown = useTransform(() => pos.get() + lag.get());\n  const turn = useTransform(() => clamp(theta.get() / 90, 0, 1));\n  /** The divider's pivot in pixels, blending from the horizontal to the vertical layout as it turns. */\n  const center = (p: number, m: Metrics): [number, number] => [\n    lerp((p / 100) * m.w, m.w / 2, turn.get()),\n    lerp(m.h / 2, (p / 100) * m.h, turn.get()),\n  ];\n  // At rest every style is a percentage, so the server renders it and a resize never lags; only a turn in progress uses pixels.\n  const clipPath = useTransform(() => {\n    const angle = theta.get(),\n      p = clamp(shown.get(), 0, 100),\n      m = metrics.get();\n    if (angle === 0 || !m.w)\n      return angle >= 45 ? `inset(0 0 ${(100 - p).toFixed(3)}% 0)` : `inset(0 ${(100 - p).toFixed(3)}% 0 0)`;\n    if (angle === 90) return `inset(0 0 ${(100 - p).toFixed(3)}% 0)`;\n    return halfPlane(m.w, m.h, center(p, m), (angle * Math.PI) / 180);\n  });\n  const rest = (angle: number, m: Metrics) => angle === 0 || angle === 90 || !m.w;\n  const pivotX = useTransform(() => {\n    const angle = theta.get(),\n      m = metrics.get();\n    return rest(angle, m) ? (angle >= 45 ? \"50%\" : `${shown.get()}%`) : `${center(shown.get(), m)[0]}px`;\n  });\n  const pivotY = useTransform(() => {\n    const angle = theta.get(),\n      m = metrics.get();\n    return rest(angle, m) ? (angle >= 45 ? `${shown.get()}%` : \"50%\") : `${center(shown.get(), m)[1]}px`;\n  });\n  /** How far the handle sits from the line along the drag axis, so it stays whole near an edge. */\n  const offset = () => {\n    const m = metrics.get();\n    if (!m.w) return 0;\n    const p = shown.get(),\n      x = (p / 100) * m.w,\n      y = (p / 100) * m.h;\n    return lerp(soften(x, m.w) - x, soften(y, m.h) - y, turn.get());\n  };\n  const handleX = useTransform(() => offset() * Math.cos((theta.get() * Math.PI) / 180));\n  const handleY = useTransform(() => offset() * Math.sin((theta.get() * Math.PI) / 180));\n  // Captions fade as the divider reaches them, and hand over mid-turn when the after caption changes corner.\n  const beforeOpacity = useTransform(() => {\n    const m = metrics.get(),\n      k = turn.get(),\n      p = clamp(shown.get(), 0, 100),\n      across = clamp(1 - k * 3, 0, 1),\n      down = clamp(k * 3 - 2, 0, 1);\n    if (!m.w) return across + down;\n    return across * fade((p / 100) * m.w - (INSET + m.bw + 8)) + down * fade((p / 100) * m.h - (INSET + m.bh + 8));\n  });\n  const afterAcross = useTransform(() => {\n    const m = metrics.get(),\n      k = clamp(1 - turn.get() * 3, 0, 1);\n    return m.w ? k * fade(m.w - INSET - m.aw - 8 - (clamp(shown.get(), 0, 100) / 100) * m.w) : k;\n  });\n  const afterDown = useTransform(() => {\n    const m = metrics.get(),\n      k = clamp(turn.get() * 3 - 2, 0, 1);\n    return m.w ? k * fade(m.h - INSET - m.ah - 8 - (clamp(shown.get(), 0, 100) / 100) * m.h) : k;\n  });\n\n  useLayoutEffect(() => {\n    const root = rootRef.current;\n    if (!root || typeof ResizeObserver === \"undefined\") return;\n    const measure = () =>\n      metrics.set({\n        w: root.clientWidth,\n        h: root.clientHeight,\n        bw: beforeChip.current?.offsetWidth ?? 0,\n        bh: beforeChip.current?.offsetHeight ?? 0,\n        aw: afterChip.current?.offsetWidth ?? 0,\n        ah: afterChip.current?.offsetHeight ?? 0,\n      });\n    const observer = new ResizeObserver(measure);\n    [root, beforeChip.current, afterChip.current].forEach((node) => node && observer.observe(node));\n    measure();\n    return () => observer.disconnect();\n  }, [metrics]);\n\n  // A new orientation swings the divider a quarter turn about its pivot while the clip follows it.\n  useEffect(() => {\n    const target = vertical ? 90 : 0;\n    if (theta.get() === target) return;\n    if (reduced) theta.jump(target);\n    else animate(theta, target, turnSpring);\n  }, [reduced, theta, vertical]);\n\n  // Positions that arrive from outside a drag (props, keys, a double-click) spring the divider there.\n  useLayoutEffect(() => {\n    latest.current = current;\n    if (drag.current?.started || goal.current === current) return;\n    goal.current = current;\n    const from = pos.get() + lag.get();\n    lag.jump(0);\n    pos.jump(from);\n    if (reduced) pos.jump(current);\n    else animate(pos, current, moveSpring);\n  });\n\n  function commit(next: number, live = false) {\n    if (live && Math.round(next) === Math.round(latest.current)) return;\n    const value = Number(next.toFixed(2));\n    if (value === latest.current) return;\n    latest.current = value;\n    if (position === undefined) setInternal(value);\n    onPositionChange?.(value);\n  }\n\n  const percentAt = (event: PointerEvent<HTMLDivElement>) => {\n    const rect = rootRef.current!.getBoundingClientRect();\n    return vertical\n      ? ((event.clientY - rect.top) / (rect.height || 1)) * 100\n      : ((event.clientX - rect.left) / (rect.width || 1)) * 100;\n  };\n  const axisSize = () => {\n    const rect = rootRef.current?.getBoundingClientRect();\n    return (vertical ? rect?.height : rect?.width) || 1;\n  };\n  /** Focus that follows a pointer keeps the handle quiet: no ring until a key is pressed. */\n  function focusQuietly() {\n    const node = handleRef.current;\n    if (!node || document.activeElement === node) return;\n    setQuiet(true);\n    node.focus({ preventScroll: true });\n  }\n\n  function start(state: Drag, at: number) {\n    const from = pos.get() + lag.get();\n    state.started = true;\n    state.grab = state.press ? 0 : at - from;\n    lag.jump(0);\n    pos.jump(from);\n    setDragging(true);\n    focusQuietly();\n    follow(state, at, true);\n  }\n  /** Puts the line under the pointer, with resistance past the edges, and commits the position once per whole percent. */\n  function follow(state: Drag, at: number, first = false) {\n    const raw = at - state.grab,\n      size = axisSize();\n    const edge = clamp(raw, 0, 100);\n    const placed = reduced ? edge : edge + (rubber(((raw - edge) / 100) * size) / size) * 100;\n    state.raw = raw;\n    if (first && state.press && !reduced) {\n      const from = pos.get();\n      pos.jump(placed);\n      lag.jump(from - placed);\n      animate(lag, 0, moveSpring);\n    } else pos.set(placed);\n    commit(edge, true);\n  }\n\n  function onPointerDown(event: PointerEvent<HTMLDivElement>) {\n    if (event.button !== 0 || !event.isPrimary || !rootRef.current) return;\n    const onHandle = !!(event.target as HTMLElement).closest(\"[data-handle]\");\n    pressedHandle.current = onHandle;\n    event.currentTarget.setPointerCapture(event.pointerId);\n    const state: Drag = {\n      pointer: event.pointerId,\n      grab: 0,\n      press: !onHandle,\n      started: false,\n      x: event.clientX,\n      y: event.clientY,\n      raw: 0,\n    };\n    drag.current = state;\n    // A finger on the photo might be scrolling the page, so it waits for a sideways drag; a mouse or the handle answers at once.\n    if (event.pointerType !== \"touch\" || onHandle) start(state, percentAt(event));\n  }\n  function onPointerMove(event: PointerEvent<HTMLDivElement>) {\n    const state = drag.current;\n    if (!state || state.pointer !== event.pointerId) return;\n    if (!state.started) {\n      const along = Math.abs(vertical ? event.clientY - state.y : event.clientX - state.x),\n        across = Math.abs(vertical ? event.clientX - state.x : event.clientY - state.y);\n      if (along < 6 && across < 6) return;\n      if (across > along) {\n        drag.current = null;\n        return;\n      }\n      start(state, percentAt(event));\n      return;\n    }\n    follow(state, percentAt(event));\n  }\n  function onPointerEnd(event: PointerEvent<HTMLDivElement>) {\n    const state = drag.current;\n    if (!state || state.pointer !== event.pointerId) return;\n    drag.current = null;\n    // A tap on the photo moves the divider there.\n    if (!state.started) {\n      if (event.type === \"pointerup\") {\n        focusQuietly();\n        commit(clamp(percentAt(event), 0, 100));\n      }\n      return;\n    }\n    // The divider stays where it was let go. Past an edge, or still catching up to a press, it springs on with the speed it had.\n    const from = pos.get() + lag.get(),\n      velocity = lag.getVelocity() + (from > 100 || from < 0 ? pos.getVelocity() : 0);\n    const target = clamp(lag.get() !== 0 ? state.raw : from, 0, 100);\n    lag.jump(0);\n    pos.jump(from);\n    goal.current = Number(target.toFixed(2));\n    if (from !== target) {\n      if (reduced) pos.jump(target);\n      else animate(pos, target, { ...moveSpring, velocity });\n    }\n    commit(target);\n    setDragging(false);\n  }\n\n  function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {\n    const large = event.shiftKey ? 10 : 1;\n    // Keys move the divider the way they point along its axis; the other two follow the value, where up and right mean more of the after image.\n    const toward: Record<string, number> = vertical\n      ? { ArrowUp: -large, ArrowDown: large, ArrowRight: -large, ArrowLeft: large, PageUp: -10, PageDown: 10 }\n      : { ArrowLeft: -large, ArrowRight: large, ArrowUp: -large, ArrowDown: large, PageUp: -10, PageDown: 10 };\n    let next: number;\n    if (event.key === \"Home\") next = 100;\n    else if (event.key === \"End\") next = 0;\n    else if (event.key in toward) next = Math.round(latest.current) + toward[event.key]!;\n    else return;\n    event.preventDefault();\n    setQuiet(false);\n    const clamped = clamp(next, 0, 100);\n    if (clamped === latest.current) {\n      if (!reduced && next !== clamped)\n        animate(pos, goal.current, {\n          ...kickSpring,\n          velocity: ((Math.sign(next - clamped) * BUMP_PX) / axisSize()) * 100,\n        });\n      return;\n    }\n    commit(clamped);\n  }\n\n  const capsule = dragging\n    ? vertical\n      ? { width: 36, height: 56 }\n      : { width: 56, height: 36 }\n    : { width: 40, height: 40 };\n  // The frame is a size container, so the divider's line can span any angle with container units. Vertical pans still scroll the page on touch.\n  // A hairline keeps light photos from bleeding into the page.\n  return (\n    // oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- the pointer surface around the divider; keyboard access lives on the role=\"slider\" handle\n    <div\n      ref={rootRef}\n      className={cn(\n        \"group/compare relative isolate block w-full cursor-ew-resize touch-pan-y overflow-clip rounded-panel bg-hover select-none [container-type:size] [-webkit-tap-highlight-color:transparent] after:pointer-events-none after:absolute after:inset-0 after:z-3 after:rounded-[inherit] after:shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--ink)_8%,transparent)] after:content-[''] data-[orientation=vertical]:cursor-ns-resize data-[orientation=vertical]:touch-pan-x\",\n        className,\n      )}\n      data-orientation={orientation}\n      data-dragging={dragging || undefined}\n      data-frame-keep=\"\"\n      style={{ aspectRatio }}\n      onPointerDown={onPointerDown}\n      onPointerMove={onPointerMove}\n      onPointerUp={onPointerEnd}\n      onPointerCancel={onPointerEnd}\n      onLostPointerCapture={onPointerEnd}\n      onMouseDown={(event) => event.preventDefault()}\n      onDragStart={(event) => event.preventDefault()}\n      // Pointer capture sends the double-click to the frame, so it checks where the presses began.\n      onDoubleClick={() => {\n        if (pressedHandle.current && latest.current !== 50) commit(50);\n      }}\n    >\n      <div className={layer}>{after}</div>\n      <motion.div className={layer} style={{ clipPath }}>\n        {before}\n      </motion.div>\n      {labels && (\n        <>\n          <motion.span\n            ref={beforeChip}\n            className={cn(chip, \"top-3 left-3\")}\n            style={{ opacity: beforeOpacity }}\n            aria-hidden=\"true\"\n          >\n            {labels[0]}\n          </motion.span>\n          <motion.span\n            ref={afterChip}\n            className={cn(chip, \"top-3 right-3\")}\n            data-place=\"end\"\n            style={{ opacity: afterAcross }}\n            aria-hidden=\"true\"\n          >\n            {labels[1]}\n          </motion.span>\n          <motion.span\n            className={cn(chip, \"bottom-3 left-3\")}\n            data-place=\"bottom\"\n            style={{ opacity: afterDown }}\n            aria-hidden=\"true\"\n          >\n            {labels[1]}\n          </motion.span>\n        </>\n      )}\n      {/* The pivot is a frame-sized layer moved by a percentage of itself; its top-left corner is the divider's center. */}\n      <motion.div className=\"pointer-events-none absolute inset-0 z-2\" style={{ x: pivotX, y: pivotY }}>\n        {/* The line sits on photography, not on the theme, so it stays light in dark mode too. */}\n        <motion.span className=\"absolute top-0 left-0 size-0\" style={{ rotate: theta }}>\n          <span className=\"absolute top-[calc((100cqw_+_100cqh)*-1)] -left-px h-[calc((100cqw_+_100cqh)*2)] w-0.5 bg-surface shadow-[0_0_0_.5px_oklch(0%_0_0/.14)] dark:bg-ink\" />\n        </motion.span>\n        <motion.span className=\"absolute top-0 left-0\" style={{ x: handleX, y: handleY }}>\n          <motion.div\n            ref={handleRef}\n            data-handle=\"\"\n            data-quiet={quiet || undefined} // Centered with the translate property; Motion only animates its width and height, so the shape morphs without scaling the icons.\n            className=\"pointer-events-auto absolute top-0 left-0 box-border grid size-10 place-items-center rounded-full border border-line bg-surface text-ink shadow-raised transition-[border-color] duration-fast ease-standard [outline:0_solid_transparent] [translate:-50%_-50%] group-hover/compare:border-line-strong after:absolute after:-inset-2 after:rounded-[inherit] after:content-[''] motion-reduce:transition-none\"\n            role=\"slider\"\n            tabIndex={0}\n            aria-label={label}\n            aria-orientation={orientation}\n            aria-valuemin={0}\n            aria-valuemax={100}\n            aria-valuenow={afterShare}\n            aria-valuetext={`${afterShare}% after`}\n            initial={false}\n            animate={capsule}\n            transition={reduced ? { duration: 0 } : spring.morph}\n            onKeyDown={onKeyDown}\n            onBlur={() => setQuiet(false)}\n          >\n            <motion.span className=\"flex items-center\" style={{ rotate: theta }} aria-hidden=\"true\">\n              <motion.span\n                className=\"-mx-[2.5px] grid place-items-center\"\n                initial={false}\n                animate={{ x: dragging ? -4 : 0 }}\n                transition={reduced ? { duration: 0 } : spring.morph}\n              >\n                <ChevronLeft size={16} strokeWidth={1.75} />\n              </motion.span>\n              <motion.span\n                className=\"-mx-[2.5px] grid place-items-center\"\n                initial={false}\n                animate={{ x: dragging ? 4 : 0 }}\n                transition={reduced ? { duration: 0 } : spring.morph}\n              >\n                <ChevronRight size={16} strokeWidth={1.75} />\n              </motion.span>\n            </motion.span>\n          </motion.div>\n        </motion.span>\n      </motion.div>\n    </div>\n  );\n}\n\nexport default ImageCompare;\n",
      "type": "registry:component",
      "target": "@components/iris/image-compare/image-compare.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/image-compare",
  "meta": {
    "kind": "component",
    "group": "Data display",
    "export": "ImageCompare",
    "tags": [
      "before after",
      "comparison",
      "image"
    ],
    "docs": "https://irisui.dev/components/image-compare"
  }
}
