{"$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://uiarc.dev/r/arc-foundation.json"],"files":[{"path":"registry/components/image-compare/image-compare.tsx","type":"registry:component","target":"@components/arc/image-compare/image-compare.tsx","content":"\"use client\";\n\nimport { useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent, type PointerEvent, type ReactNode } from \"react\";\nimport { animate, motion, useMotionValue, useReducedMotion, useTransform } from \"motion/react\";\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./image-compare.module.css\";\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\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 }) => { const root = (2 * Math.PI) / (visualDuration * 1.2); return { type: \"spring\" as const, stiffness: root * root, damping: 2 * (1 - bounce) * root, restDelta: .002, restSpeed: .02 }; };\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, MARGIN = 42, GIVE = 12, BUMP_PX = 150, 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) => Math.sign(distance) * (1 - 1 / (Math.abs(distance) * .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) => 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), ny = -Math.sin(angle);\n  const side = ([x, y]: number[]) => (x - center[0]) * nx + (y - center[1]) * ny;\n  const corners = [[0, 0], [w, 0], [w, h], [0, h]];\n  const points: number[][] = [];\n  corners.forEach((corner, index) => {\n    const next = corners[(index + 1) % 4], a = side(corner), b = side(next);\n    if (a >= 0) points.push(corner);\n    if ((a >= 0) !== (b >= 0)) { const t = a / (a - b); points.push([corner[0] + (next[0] - corner[0]) * t, corner[1] + (next[1] - corner[1]) * t]); }\n  });\n  return points.length ? `polygon(${points.map(([x, y]) => `${x.toFixed(2)}px ${y.toFixed(2)}px`).join(\", \")})` : \"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({ before, after, position, defaultPosition = 50, onPositionChange, orientation = \"horizontal\", labels = [\"Before\", \"After\"], label = \"Before and after\", aspectRatio = \"3 / 2\", className }: 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), 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] => [lerp((p / 100) * m.w, m.w / 2, turn.get()), lerp(m.h / 2, (p / 100) * m.h, turn.get())];\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(), p = clamp(shown.get(), 0, 100), m = metrics.get();\n    if (angle === 0 || !m.w) 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(() => { const angle = theta.get(), m = metrics.get(); return rest(angle, m) ? (angle >= 45 ? \"50%\" : `${shown.get()}%`) : `${center(shown.get(), m)[0]}px`; });\n  const pivotY = useTransform(() => { const angle = theta.get(), m = metrics.get(); return rest(angle, m) ? (angle >= 45 ? `${shown.get()}%` : \"50%\") : `${center(shown.get(), m)[1]}px`; });\n  /** How far the handle sits from the line along the drag axis, so it stays whole near an edge. */\n  const offset = () => { const m = metrics.get(); if (!m.w) return 0; const p = shown.get(), x = (p / 100) * m.w, y = (p / 100) * m.h; return lerp(soften(x, m.w) - x, soften(y, m.h) - y, turn.get()); };\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(), k = turn.get(), p = clamp(shown.get(), 0, 100), across = clamp(1 - k * 3, 0, 1), 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(() => { const m = metrics.get(), k = clamp(1 - turn.get() * 3, 0, 1); return m.w ? k * fade(m.w - INSET - m.aw - 8 - (clamp(shown.get(), 0, 100) / 100) * m.w) : k; });\n  const afterDown = useTransform(() => { const m = metrics.get(), k = clamp(turn.get() * 3 - 2, 0, 1); return m.w ? k * fade(m.h - INSET - m.ah - 8 - (clamp(shown.get(), 0, 100) / 100) * m.h) : k; });\n\n  useLayoutEffect(() => {\n    const root = rootRef.current;\n    if (!root || typeof ResizeObserver === \"undefined\") return;\n    const measure = () => metrics.set({ w: root.clientWidth, h: root.clientHeight, bw: beforeChip.current?.offsetWidth ?? 0, bh: beforeChip.current?.offsetHeight ?? 0, aw: afterChip.current?.offsetWidth ?? 0, ah: afterChip.current?.offsetHeight ?? 0 });\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); 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); 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 ? ((event.clientY - rect.top) / (rect.height || 1)) * 100 : ((event.clientX - rect.left) / (rect.width || 1)) * 100;\n  };\n  const axisSize = () => { const rect = rootRef.current?.getBoundingClientRect(); return (vertical ? rect?.height : rect?.width) || 1; };\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, 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 = { pointer: event.pointerId, grab: 0, press: !onHandle, started: false, x: event.clientX, y: event.clientY, raw: 0 };\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), across = Math.abs(vertical ? event.clientX - state.x : event.clientY - state.y);\n      if (along < 6 && across < 6) return;\n      if (across > along) { drag.current = null; return; }\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) { if (event.type === \"pointerup\") { focusQuietly(); commit(clamp(percentAt(event), 0, 100)); } return; }\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(), 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) { if (reduced) pos.jump(target); else animate(pos, target, { ...moveSpring, velocity }); }\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 ? { ArrowUp: -large, ArrowDown: large, ArrowRight: -large, ArrowLeft: large, PageUp: -10, PageDown: 10 } : { 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) animate(pos, goal.current, { ...kickSpring, velocity: (Math.sign(next - clamped) * BUMP_PX / axisSize()) * 100 });\n      return;\n    }\n    commit(clamped);\n  }\n\n  const capsule = dragging ? (vertical ? { width: 36, height: 56 } : { width: 56, height: 36 }) : { width: 40, height: 40 };\n  return <div ref={rootRef} className={[styles.root, className].filter(Boolean).join(\" \")} data-orientation={orientation} data-dragging={dragging || undefined} data-frame-keep=\"\" style={{ aspectRatio }}\n    onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerEnd} onPointerCancel={onPointerEnd} onLostPointerCapture={onPointerEnd} onMouseDown={event => event.preventDefault()} onDragStart={event => event.preventDefault()}\n    // Pointer capture sends the double-click to the frame, so it checks where the presses began.\n    onDoubleClick={() => { if (pressedHandle.current && latest.current !== 50) commit(50); }}>\n    <div className={styles.layer}>{after}</div>\n    <motion.div className={styles.layer} style={{ clipPath }}>{before}</motion.div>\n    {labels && <>\n      <motion.span ref={beforeChip} className={styles.chip} style={{ opacity: beforeOpacity }} aria-hidden=\"true\">{labels[0]}</motion.span>\n      <motion.span ref={afterChip} className={styles.chip} data-place=\"end\" style={{ opacity: afterAcross }} aria-hidden=\"true\">{labels[1]}</motion.span>\n      <motion.span className={styles.chip} data-place=\"bottom\" style={{ opacity: afterDown }} aria-hidden=\"true\">{labels[1]}</motion.span>\n    </>}\n    <motion.div className={styles.pivot} style={{ x: pivotX, y: pivotY }}>\n      <motion.span className={styles.rotor} style={{ rotate: theta }}><span className={styles.line} /></motion.span>\n      <motion.span className={styles.handleSlot} style={{ x: handleX, y: handleY }}>\n        <motion.div ref={handleRef} data-handle=\"\" data-quiet={quiet || undefined} className={styles.handle} role=\"slider\" tabIndex={0} aria-label={label} aria-orientation={orientation}\n          aria-valuemin={0} aria-valuemax={100} aria-valuenow={afterShare} aria-valuetext={`${afterShare}% after`}\n          initial={false} animate={capsule} transition={reduced ? { duration: 0 } : spring.morph} onKeyDown={onKeyDown} onBlur={() => setQuiet(false)}>\n          <motion.span className={styles.chevrons} style={{ rotate: theta }} aria-hidden=\"true\">\n            <motion.span className={styles.chevron} initial={false} animate={{ x: dragging ? -4 : 0 }} transition={reduced ? { duration: 0 } : spring.morph}><ChevronLeft size={16} strokeWidth={1.75} /></motion.span>\n            <motion.span className={styles.chevron} initial={false} animate={{ x: dragging ? 4 : 0 }} transition={reduced ? { duration: 0 } : spring.morph}><ChevronRight size={16} strokeWidth={1.75} /></motion.span>\n          </motion.span>\n        </motion.div>\n      </motion.span>\n    </motion.div>\n  </div>;\n}\n\nexport default ImageCompare;\n"},{"path":"registry/components/image-compare/image-compare.module.css","type":"registry:component","target":"@components/arc/image-compare/image-compare.module.css","content":"/* 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.root { position: relative; display: block; width: 100%; overflow: clip; container-type: size; isolation: isolate; border-radius: var(--radius-panel); background: var(--surface-muted); cursor: ew-resize; touch-action: pan-y; user-select: none; -webkit-user-select: none; -webkit-tap-highlight-color: transparent; }\n.root[data-orientation=\"vertical\"] { cursor: ns-resize; touch-action: pan-x; }\n/* A hairline keeps light photos from bleeding into the page. */\n.root::after { content: \"\"; position: absolute; z-index: 3; inset: 0; border-radius: inherit; box-shadow: inset 0 0 0 1px color-mix(in oklch, var(--foreground) 8%, transparent); pointer-events: none; }\n.layer { position: absolute; inset: 0; }\n.layer :where(img, picture, video) { display: block; width: 100%; height: 100%; object-fit: cover; pointer-events: none; -webkit-user-drag: none; }\n/* Captions sit on a frosted chip so they read over any photo, below the divider. */\n.chip { position: absolute; z-index: 1; top: var(--space-3); left: var(--space-3); padding: 3px 10px; border-radius: var(--radius-pill); background: color-mix(in oklch, var(--background) 74%, transparent); color: var(--foreground); font-size: var(--text-xs); font-weight: 500; line-height: var(--leading-body); letter-spacing: var(--tracking-body); pointer-events: none; -webkit-backdrop-filter: blur(12px) saturate(1.4); backdrop-filter: blur(12px) saturate(1.4); }\n.chip[data-place=\"end\"] { right: var(--space-3); left: auto; }\n.chip[data-place=\"bottom\"] { top: auto; bottom: var(--space-3); }\n/* The pivot is a frame-sized layer moved by a percentage of itself; its top-left corner is the divider's center. */\n.pivot { position: absolute; z-index: 2; inset: 0; pointer-events: none; }\n.rotor { position: absolute; top: 0; left: 0; width: 0; height: 0; }\n.line { position: absolute; top: calc((100cqw + 100cqh) * -1); left: -1px; width: 2px; height: calc((100cqw + 100cqh) * 2); background: var(--surface-raised); box-shadow: 0 0 0 .5px oklch(0% 0 0 / .14); }\n/* The line sits on photography, not on the theme, so it stays light in dark mode too. */\n:global(:root[data-theme=\"dark\"]) .line { background: var(--foreground); }\n.handleSlot { position: absolute; top: 0; left: 0; }\n/* Centered with the translate property; Motion only animates its width and height, so the shape morphs without scaling the icons. */\n.handle { position: absolute; top: 0; left: 0; display: grid; box-sizing: border-box; width: 40px; height: 40px; place-items: center; translate: -50% -50%; border: 1px solid var(--border); border-radius: var(--radius-pill); background: var(--surface-raised); box-shadow: var(--shadow-raised); color: var(--foreground); pointer-events: auto; outline: 0 solid transparent; transition: border-color var(--duration-fast) var(--ease-standard); }\n.handle::after { content: \"\"; position: absolute; inset: -8px; border-radius: inherit; }\n@media (hover: hover) and (pointer: fine) { .root:hover .handle { border-color: var(--border-strong); } }\n.chevrons { display: flex; align-items: center; }\n.chevron { display: grid; margin-inline: -2.5px; place-items: center; }\n@media (prefers-reduced-motion: reduce) { .handle { transition: none; } }\n"}],"docs":"Docs and live preview: https://uiarc.dev/components/image-compare","meta":{"tier":"free","kind":"component","tags":["before after","comparison","image"],"docs":"https://uiarc.dev/components/image-compare","markdown":"https://uiarc.dev/components/image-compare/markdown"}}