{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "image-compare",
  "title": "Image Compare",
  "type": "registry:ui",
  "description": "Before/after image comparison slider with a draggable divider. Two images overlaid via clip-path, pointer-driven (mouse + touch), horizontal or vertical orientation, custom handle render prop, labels, and disabled state.",
  "categories": [
    "display",
    "media"
  ],
  "files": [
    {
      "path": "packages/elements-lit/src/components/image-compare/image-compare.ts",
      "content": "import { LitElement, css, html, nothing } from 'lit'\nimport { MoveHorizontal, MoveVertical } from 'lucide'\nimport { cn } from '../../lib/utils'\nimport { tailwind } from '../../lib/styles'\nimport { ThemeController } from '../../lib/theme'\nimport { icon } from '../../lib/icon'\nimport { imageCompareVariants, type ImageCompareVariants } from './image-compare.variants'\n\ntype Orientation = NonNullable<ImageCompareVariants['orientation']>\n\nfunction clamp(v: number): number {\n  return Math.min(100, Math.max(0, v))\n}\n\nconst trueByDefault = {\n  fromAttribute: (v: string | null) => v !== 'false' && v !== null,\n  toAttribute: (v: boolean) => (v ? '' : 'false'),\n}\n\n/**\n * <uip-image-compare> — interactive before/after image comparison slider.\n */\nexport class UipImageCompare extends LitElement {\n  static styles = [\n    tailwind,\n    css`\n      :host {\n        display: block;\n        position: relative;\n        width: 100%;\n      }\n    `,\n  ]\n\n  static properties = {\n    beforeSrc: { attribute: 'before-src', reflect: true },\n    afterSrc: { attribute: 'after-src', reflect: true },\n    beforeAlt: { attribute: 'before-alt', reflect: true },\n    afterAlt: { attribute: 'after-alt', reflect: true },\n    beforeLabel: { attribute: 'before-label', reflect: true },\n    afterLabel: { attribute: 'after-label', reflect: true },\n    value: { type: Number, reflect: true },\n    defaultValue: { type: Number, attribute: 'default-value', reflect: true },\n    orientation: { reflect: true },\n    disabled: { type: Boolean, reflect: true },\n    showLabels: { attribute: 'show-labels', converter: trueByDefault },\n    showHandle: { attribute: 'show-handle', converter: trueByDefault },\n    position: { state: true },\n  }\n\n  beforeSrc = ''\n  afterSrc = ''\n  beforeAlt = 'Before'\n  afterAlt = 'After'\n  beforeLabel = 'Before'\n  afterLabel = 'After'\n  value?: number\n  defaultValue = 50\n  orientation: Orientation = 'horizontal'\n  disabled = false\n  showLabels = true\n  showHandle = true\n\n  private position = 50\n  private dragging = false\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'image-compare')\n    this.position = this.value !== undefined ? this.value : this.defaultValue\n  }\n\n  willUpdate(changedProps: Map<string, unknown>) {\n    if (changedProps.has('value') && this.value !== undefined) {\n      this.position = clamp(this.value)\n    }\n    this.setAttribute('data-orientation', this.orientation)\n    if (this.disabled) {\n      this.setAttribute('data-disabled', '')\n    } else {\n      this.removeAttribute('data-disabled')\n    }\n  }\n\n  private setPosition(next: number) {\n    const clamped = clamp(next)\n    this.position = clamped\n    this.value = clamped\n    this.dispatchEvent(new CustomEvent('value-change', { detail: { value: clamped }, bubbles: true, composed: true }))\n    this.dispatchEvent(new CustomEvent('change', { detail: { value: clamped }, bubbles: true, composed: true }))\n  }\n\n  private updateFromPointer(clientX: number, clientY: number) {\n    const container = this.renderRoot.querySelector<HTMLElement>('[part=base]')\n    if (!container) return\n    const rect = container.getBoundingClientRect()\n    if (this.orientation === 'horizontal') {\n      this.setPosition(((clientX - rect.left) / rect.width) * 100)\n    } else {\n      this.setPosition(((clientY - rect.top) / rect.height) * 100)\n    }\n  }\n\n  private onPointerDown(e: PointerEvent) {\n    if (this.disabled) return\n    this.dragging = true\n    const el = e.currentTarget as HTMLElement\n    try {\n      el.setPointerCapture(e.pointerId)\n    } catch {\n      // ignore pointer capture errors\n    }\n    this.updateFromPointer(e.clientX, e.clientY)\n  }\n\n  private onPointerMove(e: PointerEvent) {\n    if (!this.dragging || this.disabled) return\n    this.updateFromPointer(e.clientX, e.clientY)\n  }\n\n  private onPointerUp(e: PointerEvent) {\n    if (!this.dragging) return\n    this.dragging = false\n    const el = e.currentTarget as HTMLElement\n    try {\n      el.releasePointerCapture(e.pointerId)\n    } catch {\n      // ignore\n    }\n  }\n\n  private onKeyDown(e: KeyboardEvent) {\n    if (this.disabled) return\n    const isH = this.orientation === 'horizontal'\n    const step = e.shiftKey ? 10 : 1\n    let next = this.position\n    if (isH) {\n      if (e.key === 'ArrowLeft') next -= step\n      else if (e.key === 'ArrowRight') next += step\n      else return\n    } else {\n      if (e.key === 'ArrowUp') next -= step\n      else if (e.key === 'ArrowDown') next += step\n      else return\n    }\n    e.preventDefault()\n    this.setPosition(next)\n  }\n\n  render() {\n    const pct = this.position\n    const orientation = this.orientation\n    const clipStyle =\n      orientation === 'horizontal' ? `clip-path: inset(0 0 0 ${pct}%);` : `clip-path: inset(${pct}% 0 0 0);`\n    const dividerStyle = orientation === 'horizontal' ? `left: ${pct}%;` : `top: ${pct}%;`\n\n    return html`\n      <div\n        part=\"base\"\n        data-slot=\"image-compare\"\n        data-orientation=${orientation}\n        data-disabled=${this.disabled ? '' : nothing}\n        class=${cn(imageCompareVariants({ orientation }))}\n        style=\"touch-action: none;\"\n        @pointerdown=${this.onPointerDown}\n        @pointermove=${this.onPointerMove}\n        @pointerup=${this.onPointerUp}\n        @pointercancel=${this.onPointerUp}\n      >\n        <!-- Before image -->\n        <img\n          src=${this.beforeSrc}\n          alt=${this.beforeAlt}\n          class=\"pointer-events-none absolute inset-0 size-full object-cover select-none\"\n          draggable=\"false\"\n        />\n        ${this.showLabels\n          ? html`\n              <span\n                class=\"bg-background/80 text-foreground absolute bottom-2 left-2 rounded px-2 py-0.5 text-xs font-medium backdrop-blur-sm\"\n              >\n                ${this.beforeLabel}\n              </span>\n            `\n          : nothing}\n\n        <!-- After image (clipped overlay) -->\n        <div class=\"absolute inset-0 size-full\" style=${clipStyle}>\n          <img\n            src=${this.afterSrc}\n            alt=${this.afterAlt}\n            class=\"pointer-events-none absolute inset-0 size-full object-cover select-none\"\n            draggable=\"false\"\n          />\n          ${this.showLabels\n            ? html`\n                <span\n                  class=\"bg-background/80 text-foreground absolute right-2 bottom-2 rounded px-2 py-0.5 text-xs font-medium backdrop-blur-sm\"\n                >\n                  ${this.afterLabel}\n                </span>\n              `\n            : nothing}\n        </div>\n\n        <!-- Divider + handle -->\n        ${!this.disabled\n          ? html`\n              <div\n                class=${cn(\n                  'bg-border absolute z-10',\n                  orientation === 'horizontal' ? 'top-0 h-full w-0.5' : 'left-0 h-0.5 w-full',\n                )}\n                style=${dividerStyle}\n              >\n                ${this.showHandle\n                  ? html`\n                      <button\n                        type=\"button\"\n                        role=\"slider\"\n                        aria-valuenow=${Math.round(pct)}\n                        aria-valuemin=\"0\"\n                        aria-valuemax=\"100\"\n                        aria-label=${`Image comparison slider, ${Math.round(pct)} percent`}\n                        aria-orientation=${orientation === 'vertical' ? 'vertical' : 'horizontal'}\n                        tabindex=\"0\"\n                        class=${cn(\n                          'bg-background border-border focus-visible:ring-ring absolute flex size-9 cursor-ew-resize items-center justify-center rounded-full border shadow-md transition-transform hover:scale-110 focus-visible:ring-2 focus-visible:outline-none',\n                          'top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2',\n                          orientation === 'vertical' && 'cursor-ns-resize',\n                        )}\n                        @keydown=${this.onKeyDown}\n                        @pointerdown=${(e: PointerEvent) => {\n                          e.stopPropagation()\n                          this.onPointerDown(e)\n                        }}\n                      >\n                        <slot name=\"handle\">\n                          ${orientation === 'horizontal'\n                            ? icon(MoveHorizontal, 'move-horizontal', 'text-foreground size-4')\n                            : icon(MoveVertical, 'move-vertical', 'text-foreground size-4')}\n                        </slot>\n                      </button>\n                    `\n                  : nothing}\n              </div>\n            `\n          : nothing}\n\n        ${this.disabled ? html`<div class=\"bg-background/40 absolute inset-0\"></div>` : nothing}\n      </div>\n    `\n  }\n}\n\ncustomElements.get('uip-image-compare') || customElements.define('uip-image-compare', UipImageCompare)\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'uip-image-compare': UipImageCompare\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/components/image-compare/image-compare.ts"
    },
    {
      "path": "packages/elements-lit/src/components/image-compare/image-compare.variants.ts",
      "content": "import type { VariantProps } from 'class-variance-authority'\nimport { cva } from 'class-variance-authority'\n\nexport const imageCompareVariants = cva('bg-muted relative overflow-hidden rounded-lg border select-none', {\n  variants: {\n    orientation: {\n      horizontal: 'cursor-ew-resize',\n      vertical: 'cursor-ns-resize',\n    },\n  },\n  defaultVariants: {\n    orientation: 'horizontal',\n  },\n})\n\nexport type ImageCompareVariants = VariantProps<typeof imageCompareVariants>\n",
      "type": "registry:file",
      "target": "src/uip/components/image-compare/image-compare.variants.ts"
    }
  ],
  "dependencies": [
    "class-variance-authority",
    "lit",
    "lucide"
  ],
  "registryDependencies": [
    "https://uipkge.dev/r/lit/lit-core.json"
  ]
}
