{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "image-compare",
  "title": "Image Compare",
  "description": "A draggable before/after slider with clip-path reveal, keyboard support, and a pulsing handle hint.",
  "registryDependencies": ["@godui/godui-theme"],
  "files": [
    {
      "path": "packages/components/src/image-compare/image-compare.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport type ImageCompareProps = Omit<\n  React.HTMLAttributes<HTMLDivElement>,\n  \"onChange\"\n> & {\n  /** Content revealed on the left / top (the \"before\" state). */\n  before: React.ReactNode;\n  /** Content underneath, revealed as the handle moves (the \"after\" state). */\n  after: React.ReactNode;\n  /** Starting handle position, 0–100. */\n  initial?: number;\n  orientation?: \"horizontal\" | \"vertical\";\n  beforeLabel?: string;\n  afterLabel?: string;\n  onChange?: (position: number) => void;\n};\n\nconst clamp = (n: number) => Math.min(100, Math.max(0, n));\n\nconst ImageCompare = React.forwardRef<HTMLDivElement, ImageCompareProps>(\n  (\n    {\n      before,\n      after,\n      initial = 50,\n      orientation = \"horizontal\",\n      beforeLabel,\n      afterLabel,\n      onChange,\n      className,\n      ...props\n    },\n    ref,\n  ) => {\n    const containerRef = React.useRef<HTMLDivElement>(null);\n    React.useImperativeHandle(\n      ref,\n      () => containerRef.current as HTMLDivElement,\n    );\n    const [pos, setPos] = React.useState(clamp(initial));\n    const [dragging, setDragging] = React.useState(false);\n    const horizontal = orientation === \"horizontal\";\n\n    const updateFromPointer = React.useCallback(\n      (clientX: number, clientY: number) => {\n        const el = containerRef.current;\n        if (!el) return;\n        const rect = el.getBoundingClientRect();\n        const next = horizontal\n          ? ((clientX - rect.left) / rect.width) * 100\n          : ((clientY - rect.top) / rect.height) * 100;\n        const clamped = clamp(next);\n        setPos(clamped);\n        onChange?.(clamped);\n      },\n      [horizontal, onChange],\n    );\n\n    const handlePointerDown = (e: React.PointerEvent) => {\n      e.currentTarget.setPointerCapture(e.pointerId);\n      setDragging(true);\n      updateFromPointer(e.clientX, e.clientY);\n    };\n    const handlePointerMove = (e: React.PointerEvent) => {\n      if (!dragging) return;\n      updateFromPointer(e.clientX, e.clientY);\n    };\n    const stop = () => setDragging(false);\n\n    const handleKeyDown = (e: React.KeyboardEvent) => {\n      const step = e.shiftKey ? 10 : 2;\n      const dec = horizontal ? \"ArrowLeft\" : \"ArrowUp\";\n      const inc = horizontal ? \"ArrowRight\" : \"ArrowDown\";\n      if (e.key === dec) {\n        e.preventDefault();\n        setPos((p) => {\n          const n = clamp(p - step);\n          onChange?.(n);\n          return n;\n        });\n      } else if (e.key === inc) {\n        e.preventDefault();\n        setPos((p) => {\n          const n = clamp(p + step);\n          onChange?.(n);\n          return n;\n        });\n      }\n    };\n\n    // The \"before\" layer is clipped to the handle position.\n    const clip = horizontal\n      ? `inset(0 ${100 - pos}% 0 0)`\n      : `inset(0 0 ${100 - pos}% 0)`;\n    // Full-span divider line.\n    const lineStyle: React.CSSProperties = horizontal\n      ? { left: `${pos}%`, top: 0, bottom: 0, transform: \"translateX(-50%)\" }\n      : { top: `${pos}%`, left: 0, right: 0, transform: \"translateY(-50%)\" };\n    // Grabber centered on the opposite axis (uses the `translate` property so\n    // the `scale` hover utilities stay independent).\n    const handleStyle: React.CSSProperties = horizontal\n      ? { left: `${pos}%`, top: \"50%\", translate: \"-50% -50%\" }\n      : { top: `${pos}%`, left: \"50%\", translate: \"-50% -50%\" };\n    const transition = dragging ? \"\" : \"[transition:clip-path_120ms_ease-out]\";\n\n    return (\n      <div\n        ref={containerRef}\n        onPointerDown={handlePointerDown}\n        onPointerMove={handlePointerMove}\n        onPointerUp={stop}\n        onPointerCancel={stop}\n        className={`group relative touch-none select-none overflow-hidden rounded-xl border border-border ${\n          className ?? \"\"\n        }`}\n        {...props}\n      >\n        {/* After (base) layer */}\n        <div className=\"[&_img]:pointer-events-none [&_img]:block [&_img]:size-full [&_img]:object-cover\">\n          {after}\n        </div>\n        {afterLabel && (\n          <span className=\"pointer-events-none absolute right-3 top-3 rounded-md bg-black/60 px-2 py-1 text-xs font-medium text-white backdrop-blur-sm\">\n            {afterLabel}\n          </span>\n        )}\n\n        {/* Before (clipped) layer */}\n        <div\n          aria-hidden=\"true\"\n          style={{ clipPath: clip }}\n          className={`absolute inset-0 ${transition} [&_img]:pointer-events-none [&_img]:block [&_img]:size-full [&_img]:object-cover`}\n        >\n          {before}\n        </div>\n        {beforeLabel && (\n          <span className=\"pointer-events-none absolute left-3 top-3 rounded-md bg-black/60 px-2 py-1 text-xs font-medium text-white backdrop-blur-sm\">\n            {beforeLabel}\n          </span>\n        )}\n\n        {/* Divider + grabber */}\n        <div\n          aria-hidden=\"true\"\n          style={lineStyle}\n          className={`pointer-events-none absolute bg-white/90 shadow-[0_0_0_1px_rgba(0,0,0,0.1)] ${\n            horizontal ? \"w-0.5\" : \"h-0.5\"\n          }`}\n        />\n        <button\n          type=\"button\"\n          role=\"slider\"\n          aria-label=\"Comparison position\"\n          aria-valuemin={0}\n          aria-valuemax={100}\n          aria-valuenow={Math.round(pos)}\n          aria-orientation={orientation}\n          onKeyDown={handleKeyDown}\n          style={handleStyle}\n          className=\"absolute grid size-9 cursor-grab touch-none place-items-center rounded-full border-2 border-white bg-white/20 text-white shadow-lg backdrop-blur-sm [transition:scale_150ms_ease] active:scale-95 active:cursor-grabbing focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 group-hover:scale-110\"\n        >\n          <span className=\"absolute inset-0 animate-pulse rounded-full ring-2 ring-white/40 group-hover:hidden\" />\n          <svg\n            viewBox=\"0 0 24 24\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth={2}\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            className={`size-4 ${horizontal ? \"\" : \"rotate-90\"}`}\n            aria-hidden=\"true\"\n          >\n            <path d=\"m9 7-5 5 5 5M15 7l5 5-5 5\" />\n          </svg>\n        </button>\n      </div>\n    );\n  },\n);\nImageCompare.displayName = \"ImageCompare\";\n\nexport { ImageCompare };\n",
      "type": "registry:ui",
      "target": "components/godui/image-compare.tsx"
    }
  ],
  "type": "registry:ui"
}
