{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "image-compare",
  "type": "registry:ui",
  "title": "Image Compare",
  "description": "A before-and-after divider that follows a drag and springs to a click.",
  "categories": [
    "media"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/image-compare.tsx",
      "content": "\"use client\";\nimport { Slider } from \"@base-ui/react/slider\";\nimport { animate, motion, useMotionValue, useReducedMotion, useTransform, type AnimationPlaybackControls } from \"motion/react\";\nimport { useLayoutEffect, useRef } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\ntype Reason = \"input-change\" | \"track-press\" | \"drag\" | \"keyboard\" | \"none\";\n\nexport type ImageCompareProps = Omit<React.ComponentProps<\"div\">, \"children\" | \"defaultValue\" | \"onChange\"> & {\n  /** Usually an <img>; it's sized to cover the frame. Shown on the left (or top). */\n  before: React.ReactNode;\n  /** Shown on the right (or bottom). Must share the framing of `before`. */\n  after: React.ReactNode;\n  beforeLabel?: string;\n  afterLabel?: string;\n  /** Where the divider sits, 0–100, from the left (or top). */\n  value?: number;\n  defaultValue?: number;\n  onValueChange?: (value: number) => void;\n  orientation?: \"horizontal\" | \"vertical\";\n  /** Shape of the frame. Pass null to size it with className instead. */\n  aspectRatio?: number | string | null;\n  disabled?: boolean;\n  /** Accessible name of the divider. */\n  label?: string;\n};\n\nexport function ImageCompare({\n  before,\n  after,\n  beforeLabel = \"Before\",\n  afterLabel = \"After\",\n  value: valueProp,\n  defaultValue = 50,\n  onValueChange,\n  orientation = \"horizontal\",\n  aspectRatio = \"3 / 2\",\n  disabled = false,\n  label = \"Divider position\",\n  className,\n  style,\n  ...rest\n}: ImageCompareProps) {\n  const [value, setValue] = useControllableState({ value: valueProp, defaultValue, onChange: onValueChange });\n  const reduce = !!useReducedMotion();\n  const vertical = orientation === \"vertical\";\n  // The divider's drawn position. It follows a drag 1:1 and springs to a click, so a press\n  // anywhere on the image reads as the divider travelling there rather than teleporting.\n  const pos = useMotionValue(value);\n  const reason = useRef<Reason | null>(null);\n  const running = useRef<AnimationPlaybackControls | null>(null);\n  const knob = useRef<HTMLSpanElement>(null);\n\n  useLayoutEffect(() => {\n    running.current?.stop();\n    const why = reason.current;\n    reason.current = null;\n    if (reduce || why === \"drag\" || why === \"keyboard\" || why === \"input-change\") pos.set(value);\n    else running.current = animate(pos, value, spring.snappy);\n  }, [value, pos, reduce]);\n\n  const beforeClip = useTransform(pos, (p) => (vertical ? `inset(0 0 ${100 - p}% 0)` : `inset(0 ${100 - p}% 0 0)`));\n  const afterClip = useTransform(pos, (p) => (vertical ? `inset(${p}% 0 0 0)` : `inset(0 0 0 ${p}%)`));\n  const at = useTransform(pos, (p) => `${p}%`);\n\n  // Base UI measures vertical sliders from the bottom; the divider is measured from the top.\n  const toSlider = (v: number) => (vertical ? 100 - v : v);\n\n  return (\n    <Slider.Root\n      value={toSlider(value)}\n      onValueChange={(v, details) => {\n        reason.current = details.reason;\n        setValue(toSlider(v as number));\n      }}\n      orientation={orientation}\n      disabled={disabled}\n      min={0}\n      max={100}\n      step={1}\n      largeStep={10}\n      className={cn(\"group/compare relative isolate select-none overflow-hidden rounded-xl bg-hover\", className)}\n      style={{ aspectRatio: aspectRatio ?? undefined, ...style }}\n      onDragStart={(e) => e.preventDefault()}\n      {...rest}\n    >\n      <Slider.Control\n        onDoubleClick={(e) => {\n          // Double-clicking the handle puts it back in the middle. The slider captures the pointer,\n          // so the event lands on the control: hit-test the handle by position instead.\n          const r = knob.current?.getBoundingClientRect();\n          if (r && e.clientX >= r.left - 6 && e.clientX <= r.right + 6 && e.clientY >= r.top - 6 && e.clientY <= r.bottom + 6) {\n            reason.current = null;\n            setValue(50);\n          }\n        }}\n        className={cn(\n          \"absolute inset-0\",\n          vertical ? \"cursor-row-resize touch-pan-x\" : \"cursor-col-resize touch-pan-y\",\n          \"data-disabled:cursor-default\",\n        )}\n      >\n        <Layer>{after}</Layer>\n        <motion.div className=\"absolute inset-0\" style={{ clipPath: beforeClip }}>\n          <Layer>{before}</Layer>\n          {/* Each label belongs to its image, so the divider cuts through it rather than it floating on top. */}\n          <Tag className=\"left-3 top-3\">{beforeLabel}</Tag>\n        </motion.div>\n        <motion.div className=\"pointer-events-none absolute inset-0\" style={{ clipPath: afterClip }}>\n          <Tag className={vertical ? \"bottom-3 left-3\" : \"right-3 top-3\"}>{afterLabel}</Tag>\n        </motion.div>\n\n        <motion.div\n          aria-hidden\n          className={cn(\n            \"pointer-events-none absolute flex items-center justify-center\",\n            vertical ? \"inset-x-0 h-0 -translate-y-1/2\" : \"inset-y-0 w-0 -translate-x-1/2\",\n          )}\n          style={vertical ? { top: at } : { left: at }}\n        >\n          {/* The line: near-white in both themes, with a hairline so it holds on bright photos too. */}\n          <span className={cn(\"absolute bg-raised shadow-[var(--shadow)] ring-1 ring-fg/15 dark:bg-fg dark:ring-frame/40\", vertical ? \"inset-x-0 h-0.5\" : \"inset-y-0 w-0.5\")} />\n          <span\n            ref={knob}\n            className={cn(\n              \"pointer-events-auto relative grid size-9 shrink-0 place-items-center rounded-full border border-line-2 bg-raised text-fg shadow-pop dark:border-transparent dark:bg-fg dark:text-frame\",\n              \"transition-[scale,outline-color] duration-150 ease-out-expo\",\n              // A squash on press, held while dragging; it lets go the moment you do.\n              \"group-active/compare:scale-[0.92] group-data-dragging/compare:scale-[0.92] motion-reduce:scale-100!\",\n              \"outline-1 outline-offset-2 outline-transparent group-has-[input:focus-visible]/compare:outline-solid group-has-[input:focus-visible]/compare:outline-fg-3\",\n              \"before:absolute before:-inset-1.5 before:rounded-full before:content-['']\",\n              \"group-data-disabled/compare:opacity-60\",\n            )}\n          >\n            <Arrows vertical={vertical} />\n          </span>\n        </motion.div>\n\n        <Slider.Track className=\"absolute inset-0\">\n          <Slider.Thumb\n            aria-label={label}\n            getAriaValueText={(_, v) => {\n              const shown = Math.round(vertical ? 100 - v : v);\n              return `${shown}% ${beforeLabel.toLowerCase()}, ${100 - shown}% ${afterLabel.toLowerCase()}`;\n            }}\n            className=\"pointer-events-none size-px opacity-0\"\n          />\n        </Slider.Track>\n      </Slider.Control>\n    </Slider.Root>\n  );\n}\n\nfunction Layer({ children }: { children: React.ReactNode }) {\n  return (\n    <div className=\"pointer-events-none absolute inset-0 *:absolute *:inset-0 *:size-full *:object-cover *:select-none [&_img]:[-webkit-user-drag:none]\">\n      {children}\n    </div>\n  );\n}\n\nfunction Tag({ className, children }: { className?: string; children: React.ReactNode }) {\n  return (\n    <span\n      className={cn(\n        \"pointer-events-none absolute flex h-6 items-center rounded-full bg-page/75 px-2.5 text-[11.5px] font-medium text-fg backdrop-blur-sm\",\n        className,\n      )}\n    >\n      {children}\n    </span>\n  );\n}\n\n// Two chevrons that part while you drag, like the handle is being pulled open.\nfunction Arrows({ vertical }: { vertical: boolean }) {\n  const part = \"transition-transform duration-200 ease-out-expo motion-reduce:transition-none\";\n  return (\n    <svg width=\"18\" height=\"18\" viewBox=\"0 0 18 18\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.5} strokeLinecap=\"round\" strokeLinejoin=\"round\" className={vertical ? \"rotate-90\" : undefined}>\n      <path d=\"M6.5 5.5 3 9l3.5 3.5\" className={cn(part, \"group-hover/compare:-translate-x-px group-data-dragging/compare:-translate-x-[2px]\")} />\n      <path d=\"M11.5 5.5 15 9l-3.5 3.5\" className={cn(part, \"group-hover/compare:translate-x-px group-data-dragging/compare:translate-x-[2px]\")} />\n    </svg>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/image-compare"
}
