{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"image-compare","type":"registry:component","title":"Image compare","description":"Two images, one precise divide. Drag to reveal what changed, then let the handle settle.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/text-morph.json"],"files":[{"path":"registry/image-compare.tsx","type":"registry:component","content":"// Hairline UI · image-compare · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'use client';\n\n/* ─────────────────────────────────────────────────────────\n * IMAGE COMPARE: two pictures, one divide\n *\n *   drag     press anywhere and the divide is thrown to your\n *            finger, then follows it exactly\n *   reveal   the \"after\" picture sits in a window that slides one\n *            way while the picture inside slides back the other:\n *            two moving layers, nothing repainted while you drag\n *   labels   each side's label fades as its picture runs out\n *   keys     arrows by 1%, Shift by 10%, Home and End; a\n *            double-click brings the divide home to the middle\n * ───────────────────────────────────────────────────────── */\n\nimport * as React from 'react';\nimport { ChevronsLeftRight, ImageOff } from 'lucide-react';\nimport { EASE_OUT, INTERACTION_FOCUS, useInteractionReducedMotion } from './ui-interaction-motion';\nimport { TextMorph } from './text-morph';\n\nexport type CompareImage = { src: string; alt: string; className?: string };\nexport interface ImageCompareProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {\n  before: CompareImage;\n  after: CompareImage;\n  beforeLabel?: string;\n  afterLabel?: string;\n  label?: string;\n  value?: number;\n  defaultValue?: number;\n  onValueChange?: (value: number) => void;\n  onValueCommit?: (value: number) => void;\n  aspectRatio?: number;\n}\nconst clamp = (value: number) => (Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 50);\n\nfunction Picture({ image }: { image: CompareImage }) {\n  const [failed, setFailed] = React.useState(false);\n  return failed ? (\n    <div role=\"img\" aria-label={`${image.alt} (unavailable)`} className=\"flex size-full items-center justify-center bg-muted text-muted-foreground\">\n      <ImageOff className=\"size-6\" aria-hidden=\"true\" />\n    </div>\n  ) : (\n    <img src={image.src} alt={image.alt} draggable={false} decoding=\"async\" onError={() => setFailed(true)} className={`size-full select-none object-cover ${image.className ?? ''}`} />\n  );\n}\n\n// The same strings are written by React and by the gesture, so either can take over from the other.\nconst windowAt = (p: number) => `translate3d(${p - 100}%,0,0)`;\nconst pictureAt = (p: number) => `translate3d(${100 - p}%,0,0)`;\nconst lineAt = (p: number) => `translate3d(${p}%,0,0)`;\nconst handleAt = (p: number) => `translate3d(clamp(22px, ${p}%, calc(100% - 22px)),0,0)`;\n\nexport function ImageCompare({ before, after, beforeLabel = 'Before', afterLabel = 'After', label = 'Image comparison', value, defaultValue = 50, onValueChange, onValueCommit, aspectRatio = 1.6, className = '', ...props }: ImageCompareProps) {\n  const reduced = useInteractionReducedMotion();\n  const [own, setOwn] = React.useState(clamp(defaultValue));\n  const position = clamp(value ?? own);\n  const frame = React.useRef<HTMLDivElement>(null);\n  const win = React.useRef<HTMLDivElement>(null);\n  const picture = React.useRef<HTMLDivElement>(null);\n  const line = React.useRef<HTMLDivElement>(null);\n  const rail = React.useRef<HTMLDivElement>(null);\n  const handle = React.useRef<HTMLButtonElement>(null);\n  const afterTag = React.useRef<HTMLSpanElement>(null);\n  const beforeTag = React.useRef<HTMLSpanElement>(null);\n  const gesture = React.useRef<{ id: number; origin: number; last: number; left: number; width: number; moved: boolean } | null>(null);\n  const hintId = React.useId();\n\n  const motion = (on: boolean) => {\n    const t = on && !reduced ? `transform 420ms ${EASE_OUT}` : 'none';\n    for (const el of [win.current, picture.current, line.current, rail.current]) if (el) el.style.transition = t;\n  };\n  const paint = (p: number) => {\n    if (win.current) win.current.style.transform = windowAt(p);\n    if (picture.current) picture.current.style.transform = pictureAt(p);\n    if (line.current) line.current.style.transform = lineAt(p);\n    if (rail.current) rail.current.style.transform = handleAt(p);\n    if (afterTag.current) afterTag.current.style.opacity = p > 12 ? '1' : '0';\n    if (beforeTag.current) beforeTag.current.style.opacity = p < 88 ? '1' : '0';\n    handle.current?.setAttribute('aria-valuenow', String(Math.round(p)));\n    handle.current?.setAttribute('aria-valuetext', `${Math.round(p)}% ${afterLabel}`);\n  };\n  /** Settle on a value: React state, the parent, and an eased move when it wasn't under the finger. */\n  const go = (next: number, animate: boolean, commit = true) => {\n    const p = clamp(next);\n    motion(animate);\n    paint(p);\n    if (value === undefined) setOwn(p);\n    onValueChange?.(p);\n    if (commit) onValueCommit?.(p);\n    return p;\n  };\n\n  // A controlled value that changes from outside eases there.\n  React.useLayoutEffect(() => {\n    if (gesture.current) return;\n    motion(true);\n    paint(position);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [position]);\n\n  const finish = (event: React.PointerEvent, cancelled = false) => {\n    const state = gesture.current;\n    if (!state || event.pointerId !== state.id) return;\n    gesture.current = null;\n    // A release near the middle snaps home.\n    const next = cancelled ? state.origin : Math.abs(state.last - 50) < 3 ? 50 : state.last;\n    go(next, cancelled || next !== state.last || !state.moved, !cancelled);\n  };\n\n  return (\n    <div {...props} role=\"region\" aria-label={label} className={`w-full text-foreground ${className}`}>\n      <div\n        ref={frame}\n        className=\"relative isolate cursor-ew-resize overflow-clip rounded-[20px] border border-border bg-muted\"\n        style={{ aspectRatio: Math.max(0.25, aspectRatio), touchAction: 'pan-y' }}\n        onPointerDown={(event) => {\n          if (event.button !== 0 || !event.isPrimary) return;\n          // Keep the handle focused: the mousedown that follows would otherwise move focus to the page.\n          event.preventDefault();\n          const rect = event.currentTarget.getBoundingClientRect();\n          event.currentTarget.setPointerCapture(event.pointerId);\n          // Focus follows the press so the arrow keys work next, without a keyboard focus ring after a mouse drag.\n          handle.current?.focus({ preventScroll: true, focusVisible: false } as FocusOptions);\n          const p = clamp(((event.clientX - rect.left) / Math.max(1, rect.width)) * 100);\n          gesture.current = { id: event.pointerId, origin: position, last: p, left: rect.left, width: Math.max(1, rect.width), moved: false };\n          // Thrown to the finger, then attached to it.\n          motion(true);\n          paint(p);\n        }}\n        onPointerMove={(event) => {\n          const state = gesture.current;\n          if (!state || state.id !== event.pointerId) return;\n          if (!state.moved) {\n            state.moved = true;\n            motion(false);\n          }\n          state.last = clamp(((event.clientX - state.left) / state.width) * 100);\n          paint(state.last);\n          onValueChange?.(state.last);\n        }}\n        onDoubleClick={() => go(50, true)}\n        onPointerUp={(event) => finish(event)}\n        onPointerCancel={(event) => finish(event, true)}\n        onLostPointerCapture={(event) => finish(event, true)}\n      >\n        <div className=\"absolute inset-0\">\n          <Picture key={before.src} image={before} />\n        </div>\n        <div ref={win} className=\"absolute inset-0 overflow-hidden will-change-transform\" style={{ transform: windowAt(position) }}>\n          <div ref={picture} className=\"absolute inset-0 will-change-transform\" style={{ transform: pictureAt(position) }}>\n            <Picture key={after.src} image={after} />\n          </div>\n        </div>\n        <div ref={line} aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 will-change-transform\" style={{ transform: lineAt(position) }}>\n          <span className=\"absolute inset-y-0 left-0 w-px -translate-x-1/2 bg-background\" />\n        </div>\n        <div ref={rail} className=\"pointer-events-none absolute inset-0 will-change-transform\" style={{ transform: handleAt(position) }}>\n          <button\n            ref={handle}\n            type=\"button\"\n            role=\"slider\"\n            aria-label={label}\n            aria-describedby={hintId}\n            aria-valuemin={0}\n            aria-valuemax={100}\n            aria-valuenow={Math.round(position)}\n            aria-valuetext={`${Math.round(position)}% ${afterLabel}`}\n            aria-orientation=\"horizontal\"\n            onKeyDown={(event) => {\n              if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) return;\n              event.preventDefault();\n              const step = (event.key === 'ArrowRight' || event.key === 'ArrowUp' ? 1 : -1) * (event.shiftKey ? 10 : 1);\n              go(event.key === 'Home' ? 0 : event.key === 'End' ? 100 : position + step, true);\n            }}\n            className={`pointer-events-auto absolute left-0 top-1/2 flex size-10 -translate-x-1/2 -translate-y-1/2 cursor-ew-resize items-center justify-center rounded-full border border-border bg-background text-foreground ${INTERACTION_FOCUS}`}\n          >\n            <ChevronsLeftRight className=\"size-4\" aria-hidden=\"true\" />\n          </button>\n        </div>\n        <span\n          ref={afterTag}\n          className=\"pointer-events-none absolute bottom-3 left-3 rounded-full border border-border bg-background px-3 py-1.5 text-[10px] font-medium transition-opacity duration-300 motion-reduce:transition-none\"\n          style={{ opacity: position > 12 ? 1 : 0 }}\n        >\n          <TextMorph>{afterLabel}</TextMorph>\n        </span>\n        <span\n          ref={beforeTag}\n          className=\"pointer-events-none absolute bottom-3 right-3 rounded-full border border-border bg-background px-3 py-1.5 text-[10px] font-medium transition-opacity duration-300 motion-reduce:transition-none\"\n          style={{ opacity: position < 88 ? 1 : 0 }}\n        >\n          <TextMorph>{beforeLabel}</TextMorph>\n        </span>\n      </div>\n      <p id={hintId} className=\"sr-only\">\n        Drag to compare. Arrow keys adjust by one percent, Shift and arrows by ten. Home and End reveal either image. Double-click to centre.\n      </p>\n    </div>\n  );\n}\n"},{"path":"registry/ui-interaction-motion.ts","type":"registry:component","content":"// Hairline UI · image-compare · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'use client';\n\nimport * as React from 'react';\n\nexport const EASE_OUT = 'cubic-bezier(0.16, 1, 0.3, 1)';\nexport const EASE_SETTLE = 'cubic-bezier(0.22, 1.18, 0.36, 1)';\nexport const INTERACTION_FOCUS = 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background';\n\nconst query = '(prefers-reduced-motion: reduce)';\nconst subscribe = (notify: () => void) => {\n  const media = window.matchMedia(query);\n  media.addEventListener('change', notify);\n  return () => media.removeEventListener('change', notify);\n};\nexport const useInteractionReducedMotion = () => React.useSyncExternalStore(subscribe, () => window.matchMedia(query).matches, () => false);\n\nexport function useInteractionActivity<T extends Element>() {\n  const ref = React.useRef<T>(null);\n  const [visible, setVisible] = React.useState(false);\n  const [hidden, setHidden] = React.useState(false);\n  React.useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));\n    observer.observe(el);\n    const visibility = () => setHidden(document.hidden);\n    visibility(); document.addEventListener('visibilitychange', visibility);\n    return () => { observer.disconnect(); document.removeEventListener('visibilitychange', visibility); };\n  }, []);\n  return { ref, active: visible && !hidden };\n}\n\nexport function interactionIcon(on: boolean, reduced: boolean): React.CSSProperties {\n  return { opacity: on ? 1 : 0, transform: on ? 'none' : 'scale(0.6)', filter: on ? 'blur(0px)' : 'blur(3px)', transition: reduced ? 'none' : `opacity 220ms ${EASE_OUT}, transform 380ms ${EASE_OUT}, filter 220ms ${EASE_OUT}` };\n}\n\n/** Sample recent movement rather than averaging a long drag and losing its release velocity. */\nexport type PointerSample = { x: number; time: number };\nexport function samplePointer(samples: PointerSample[], x: number, time: number) {\n  samples.push({ x, time });\n  while (samples.length > 2 && time - samples[0].time > 100) samples.shift();\n}\nexport function releaseVelocity(samples: PointerSample[], now: number) {\n  if (samples.length < 2 || now - samples[samples.length - 1].time > 100) return 0;\n  const first = samples[0], last = samples[samples.length - 1];\n  return (last.x - first.x) / Math.max(1, last.time - first.time);\n}\n\n/** A damped spring sampled by WAAPI: no React renders or perpetual frame loop. */\nexport function springFrames(from: number, to: number, velocity = 0, duration = 520) {\n  const omega = 20, damping = 0.82, damped = omega * Math.sqrt(1 - damping * damping);\n  const displacement = from - to;\n  const coefficient = (velocity * 1000 + damping * omega * displacement) / damped;\n  return Array.from({ length: 33 }, (_, i) => {\n    const t = (i / 32) * duration / 1000;\n    const value = to + Math.exp(-damping * omega * t) * (displacement * Math.cos(damped * t) + coefficient * Math.sin(damped * t));\n    return { value: i === 32 ? to : value, offset: i / 32 };\n  });\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/image-compare. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}