{
  "$schema": "https://blode.co/ui/schema/registry-item.json",
  "name": "image-comparison",
  "title": "Image Comparison",
  "author": "Matthew Blode",
  "description": "A draggable slider that wipes between a before and after image.",
  "dependencies": ["blode-icons-react", "class-variance-authority"],
  "files": [
    {
      "path": "ui/image-comparison.tsx",
      "content": "\"use client\";\n\nimport { ArrowExpandHorIcon, ArrowExpandVerIcon } from \"blode-icons-react\";\nimport { cva } from \"class-variance-authority\";\nimport type * as React from \"react\";\nimport { useRef, useState } from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { AspectRatio } from \"@/components/ui/aspect-ratio\";\n\nconst MIN_POSITION = 0;\nconst MAX_POSITION = 100;\nconst STEP = 1;\nconst LARGE_STEP = 10;\n\nconst clampPosition = (value: number) => Math.min(MAX_POSITION, Math.max(MIN_POSITION, value));\n\nconst imageComparisonHandleVariants = cva(\n  \"group/handle absolute flex touch-none items-center justify-center outline-none\",\n  {\n    defaultVariants: {\n      orientation: \"horizontal\",\n    },\n    variants: {\n      orientation: {\n        horizontal: \"-translate-x-1/2 inset-y-0 w-10 cursor-ew-resize\",\n        vertical: \"-translate-y-1/2 inset-x-0 h-10 cursor-ns-resize\",\n      },\n    },\n  },\n);\n\nconst imageComparisonLineVariants = cva(\"absolute bg-primary\", {\n  defaultVariants: {\n    orientation: \"horizontal\",\n  },\n  variants: {\n    orientation: {\n      horizontal: \"inset-y-0 w-0.5\",\n      vertical: \"inset-x-0 h-0.5\",\n    },\n  },\n});\n\nexport interface ImageComparisonImage {\n  /** Alternative text for the image. Required — each side is distinct content. */\n  alt: string;\n  /** Image source. */\n  src: string;\n}\n\nexport interface ImageComparisonProps extends Omit<React.ComponentProps<\"div\">, \"onChange\"> {\n  /** The image revealed on the trailing side of the handle. */\n  after: ImageComparisonImage;\n  /** The image revealed on the leading side of the handle. */\n  before: ImageComparisonImage;\n  /** Uncontrolled starting position, 0–100 (default: 50). */\n  defaultPosition?: number;\n  /** Accessible name for the drag handle (default: \"Comparison position\"). */\n  label?: string;\n  /** Called with the clamped 0–100 position whenever it changes. */\n  onPositionChange?: (position: number) => void;\n  /** Axis the handle travels along (default: \"horizontal\"). */\n  orientation?: \"horizontal\" | \"vertical\";\n  /** Controlled position, 0–100. Pass with `onPositionChange`. */\n  position?: number;\n  /**\n   * Width divided by height. The container reserves it so the images cannot\n   * shift layout as they load (default: `16 / 9`).\n   */\n  ratio?: number;\n}\n\nconst ImageComparison = ({\n  after,\n  before,\n  className,\n  defaultPosition = 50,\n  label = \"Comparison position\",\n  onPositionChange,\n  orientation = \"horizontal\",\n  position,\n  ratio = 16 / 9,\n  ...props\n}: ImageComparisonProps) => {\n  const containerRef = useRef<HTMLDivElement>(null);\n  const [isDragging, setIsDragging] = useState(false);\n  const [uncontrolledPosition, setUncontrolledPosition] = useState(() =>\n    clampPosition(defaultPosition),\n  );\n\n  const isControlled = position !== undefined;\n  const currentPosition = clampPosition(isControlled ? position : uncontrolledPosition);\n  const isVertical = orientation === \"vertical\";\n\n  const setPosition = (next: number) => {\n    const clamped = clampPosition(next);\n    if (clamped === currentPosition) {\n      return;\n    }\n    if (!isControlled) {\n      setUncontrolledPosition(clamped);\n    }\n    onPositionChange?.(clamped);\n  };\n\n  const updateFromPointer = (clientX: number, clientY: number) => {\n    const rect = containerRef.current?.getBoundingClientRect();\n    if (!rect) {\n      return;\n    }\n    setPosition(\n      isVertical\n        ? ((clientY - rect.top) / rect.height) * 100\n        : ((clientX - rect.left) / rect.width) * 100,\n    );\n  };\n\n  const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {\n    event.currentTarget.setPointerCapture(event.pointerId);\n    event.currentTarget.focus();\n    setIsDragging(true);\n  };\n\n  const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {\n    if (!event.currentTarget.hasPointerCapture(event.pointerId)) {\n      return;\n    }\n    updateFromPointer(event.clientX, event.clientY);\n  };\n\n  const onPointerUp = (event: React.PointerEvent<HTMLDivElement>) => {\n    if (event.currentTarget.hasPointerCapture(event.pointerId)) {\n      event.currentTarget.releasePointerCapture(event.pointerId);\n    }\n    setIsDragging(false);\n  };\n\n  const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    const decreaseKey = isVertical ? \"ArrowUp\" : \"ArrowLeft\";\n    const increaseKey = isVertical ? \"ArrowDown\" : \"ArrowRight\";\n    // Page keys travel the way the arrows do. Vertically, position grows downward,\n    // so PageUp has to lower it or it contradicts ArrowUp on the same handle.\n    const pageDecreaseKey = isVertical ? \"PageUp\" : \"PageDown\";\n    const pageIncreaseKey = isVertical ? \"PageDown\" : \"PageUp\";\n    const step = event.shiftKey ? LARGE_STEP : STEP;\n    let next: number | undefined;\n\n    if (event.key === decreaseKey) {\n      next = currentPosition - step;\n    } else if (event.key === increaseKey) {\n      next = currentPosition + step;\n    } else if (event.key === pageDecreaseKey) {\n      next = currentPosition - LARGE_STEP;\n    } else if (event.key === pageIncreaseKey) {\n      next = currentPosition + LARGE_STEP;\n    } else if (event.key === \"Home\") {\n      next = MIN_POSITION;\n    } else if (event.key === \"End\") {\n      next = MAX_POSITION;\n    }\n\n    if (next === undefined) {\n      return;\n    }\n    event.preventDefault();\n    setPosition(next);\n  };\n\n  const rounded = Math.round(currentPosition);\n  const clipPath = isVertical\n    ? `polygon(0% 0%, 100% 0%, 100% ${currentPosition}%, 0% ${currentPosition}%)`\n    : `polygon(0% 0%, ${currentPosition}% 0%, ${currentPosition}% 100%, 0% 100%)`;\n  const HandleIcon = isVertical ? ArrowExpandVerIcon : ArrowExpandHorIcon;\n\n  return (\n    <AspectRatio\n      className={cn(\"w-full select-none overflow-hidden rounded-xl bg-muted\", className)}\n      data-orientation={orientation}\n      data-slot=\"image-comparison\"\n      ratio={ratio}\n      ref={containerRef}\n      {...props}\n    >\n      {/* eslint-disable-next-line next/no-img-element -- caller-supplied source, may be a data/blob URL */}\n      <img\n        alt={after.alt}\n        className=\"absolute inset-0 size-full select-none object-cover\"\n        data-slot=\"image-comparison-after\"\n        draggable={false}\n        src={after.src}\n      />\n\n      <div className=\"absolute inset-0\" data-slot=\"image-comparison-before\" style={{ clipPath }}>\n        {/* eslint-disable-next-line next/no-img-element -- caller-supplied source, may be a data/blob URL */}\n        <img\n          alt={before.alt}\n          className=\"size-full select-none object-cover\"\n          draggable={false}\n          src={before.src}\n        />\n      </div>\n\n      {/* oxlint-disable jsx-a11y/prefer-tag-over-role -- role=\"slider\" is the WAI-ARIA pattern for a drag handle; input[type=range] cannot sit between two clipped images */}\n      <div\n        aria-label={label}\n        aria-orientation={orientation}\n        aria-valuemax={MAX_POSITION}\n        aria-valuemin={MIN_POSITION}\n        aria-valuenow={rounded}\n        aria-valuetext={`${rounded}%`}\n        className={imageComparisonHandleVariants({ orientation })}\n        data-dragging={isDragging || undefined}\n        data-slot=\"image-comparison-handle\"\n        onKeyDown={onKeyDown}\n        onPointerCancel={onPointerUp}\n        onPointerDown={onPointerDown}\n        onPointerMove={onPointerMove}\n        onPointerUp={onPointerUp}\n        role=\"slider\"\n        style={isVertical ? { top: `${currentPosition}%` } : { left: `${currentPosition}%` }}\n        tabIndex={0}\n      >\n        <div\n          className={imageComparisonLineVariants({ orientation })}\n          data-slot=\"image-comparison-line\"\n        />\n        <div\n          className=\"absolute flex size-10 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-sm transition-[box-shadow] duration-150 ease-out group-focus-visible/handle:ring-2 group-focus-visible/handle:ring-ring/50 group-focus-visible/handle:ring-offset-2 group-focus-visible/handle:ring-offset-background\"\n          data-slot=\"image-comparison-thumb\"\n        >\n          <HandleIcon className=\"size-4\" />\n        </div>\n      </div>\n      {/* oxlint-enable jsx-a11y/prefer-tag-over-role */}\n    </AspectRatio>\n  );\n};\n\nexport { ImageComparison };\n",
      "type": "registry:ui",
      "target": ""
    }
  ],
  "type": "registry:ui"
}
