{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "compare-slider",
  "type": "registry:ui",
  "title": "Compare Slider",
  "description": "A before / after comparison with a draggable, keyboard-operable divider revealed by clip-path.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/compare-slider.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { animate, motion, useMotionValue, useMotionValueEvent, useReducedMotion, useTransform } from \"motion/react\";\n\nexport interface CompareSliderProps\n  extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\" | \"defaultValue\" | \"onChange\"> {\n  /** Shown on the start side (left / top). An image URL or any element. */\n  before: React.ReactNode;\n  /** Shown on the end side (right / bottom). An image URL or any element. */\n  after: React.ReactNode;\n  /** Label for the before side (also the image alt text when `before` is a URL). */\n  beforeLabel?: string;\n  /** Label for the after side (also the image alt text when `after` is a URL). */\n  afterLabel?: string;\n  /** Show the before / after chips. */\n  showLabels?: boolean;\n  /** Split side by side (horizontal) or top / bottom (vertical). */\n  orientation?: \"horizontal\" | \"vertical\";\n  /** Divider position in % (controlled). */\n  position?: number;\n  /** Initial divider position in % (uncontrolled). */\n  defaultPosition?: number;\n  /** Called with the new position in % whenever it changes. */\n  onPositionChange?: (position: number) => void;\n  /** Move the divider by hovering with a mouse, without pressing. */\n  followPointer?: boolean;\n  /** Keyboard step in %. */\n  step?: number;\n  /** Handle and focus ring colour. */\n  accent?: string;\n  /** Corner radius in px. */\n  rounded?: number;\n  /** Accessible name of the divider handle. */\n  handleLabel?: string;\n}\n\nconst clamp = (n: number) => Math.min(100, Math.max(0, n));\n\nfunction Layer({ content, alt }: { content: React.ReactNode; alt: string }) {\n  if (typeof content === \"string\") {\n    // eslint-disable-next-line @next/next/no-img-element -- the shipped component must not depend on next/image\n    return <img src={content} alt={alt} draggable={false} className=\"block size-full object-cover\" />;\n  }\n  return <div className=\"size-full\">{content}</div>;\n}\n\n/**\n * A before / after comparison with a draggable divider. The before layer is revealed with a\n * clip-path driven by a motion value, so dragging never triggers layout or React renders.\n * The handle is a keyboard-operable role=\"slider\".\n */\nexport function CompareSlider({\n  before,\n  after,\n  beforeLabel = \"Before\",\n  afterLabel = \"After\",\n  showLabels = true,\n  orientation = \"horizontal\",\n  position,\n  defaultPosition = 50,\n  onPositionChange,\n  followPointer = false,\n  step = 1,\n  accent = \"#FF7448\",\n  rounded = 16,\n  handleLabel = \"Comparison divider\",\n  className = \"\",\n  style,\n  ...props\n}: CompareSliderProps) {\n  const horizontal = orientation === \"horizontal\";\n  const reduceMotion = useReducedMotion();\n  const [initial] = React.useState(() => clamp(position ?? defaultPosition));\n  const pos = useMotionValue(initial);\n\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const handleRef = React.useRef<HTMLDivElement>(null);\n  const dragging = React.useRef(false);\n\n  const clipPath = useTransform(pos, (v) => (horizontal ? `inset(0 ${100 - v}% 0 0)` : `inset(0 0 ${100 - v}% 0)`));\n  const offset = useTransform(pos, (v) => `${v}%`);\n  const beforeOpacity = useTransform(pos, [10, 22], [0, 1]);\n  const afterOpacity = useTransform(pos, [78, 90], [1, 0]);\n\n  const valueText = (v: number) => `${beforeLabel} ${Math.round(v)}%, ${afterLabel} ${100 - Math.round(v)}%`;\n\n  // Keep the ARIA value in sync without re-rendering on every pointer move.\n  useMotionValueEvent(pos, \"change\", (v) => {\n    const handle = handleRef.current;\n    if (!handle) return;\n    handle.setAttribute(\"aria-valuenow\", String(Math.round(v)));\n    handle.setAttribute(\"aria-valuetext\", valueText(v));\n  });\n\n  // Controlled mode: follow the prop.\n  React.useEffect(() => {\n    if (position === undefined || dragging.current) return;\n    const target = clamp(position);\n    if (Math.abs(pos.get() - target) < 0.01) return;\n    const controls = animate(pos, target, reduceMotion ? { duration: 0 } : { type: \"spring\", stiffness: 500, damping: 40 });\n    return () => controls.stop();\n  }, [position, pos, reduceMotion]);\n\n  const setPosition = (next: number, animated = false) => {\n    const v = clamp(next);\n    if (animated && !reduceMotion) animate(pos, v, { type: \"spring\", stiffness: 600, damping: 42 });\n    else pos.set(v);\n    onPositionChange?.(v);\n  };\n\n  const fromPointer = (event: React.PointerEvent<HTMLDivElement>) => {\n    const rect = rootRef.current?.getBoundingClientRect();\n    if (!rect || rect.width === 0 || rect.height === 0) return;\n    setPosition(\n      horizontal ? ((event.clientX - rect.left) / rect.width) * 100 : ((event.clientY - rect.top) / rect.height) * 100,\n    );\n  };\n\n  const endDrag = () => {\n    dragging.current = false;\n  };\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    const current = pos.get();\n    const big = Math.max(step, 10);\n    // Right / down move the divider toward the end side; left / up toward the start.\n    const map: Record<string, number> = {\n      ArrowRight: current + step,\n      ArrowDown: current + step,\n      ArrowLeft: current - step,\n      ArrowUp: current - step,\n      PageDown: current + big,\n      PageUp: current - big,\n      Home: 0,\n      End: 100,\n    };\n    if (!(event.key in map)) return;\n    event.preventDefault();\n    setPosition(map[event.key], true);\n  };\n\n  const chip =\n    \"pointer-events-none absolute rounded-full bg-zinc-950/55 px-2.5 py-1 font-mono text-[10px] uppercase tracking-[0.14em] text-white backdrop-blur-sm\";\n\n  return (\n    <div\n      ref={rootRef}\n      className={`relative isolate select-none overflow-hidden bg-zinc-200 dark:bg-zinc-800 ${horizontal ? \"touch-pan-y cursor-ew-resize\" : \"touch-pan-x cursor-ns-resize\"} ${className}`}\n      style={{ borderRadius: rounded, [\"--cs-accent\" as string]: accent, ...style }}\n      onPointerDown={(event) => {\n        if (event.button !== 0) return;\n        event.preventDefault();\n        event.currentTarget.setPointerCapture(event.pointerId);\n        handleRef.current?.focus({ preventScroll: true });\n        dragging.current = true;\n        fromPointer(event);\n      }}\n      onPointerMove={(event) => {\n        if (dragging.current || (followPointer && event.pointerType === \"mouse\")) fromPointer(event);\n      }}\n      onPointerUp={endDrag}\n      onPointerCancel={endDrag}\n      onLostPointerCapture={endDrag}\n      {...props}\n    >\n      {/* After: in flow, so an image can size the component. */}\n      <div className=\"relative size-full\">\n        <Layer content={after} alt={afterLabel} />\n      </div>\n\n      {/* Before: stacked on top and clipped to the divider. */}\n      <motion.div className=\"absolute inset-0\" style={{ clipPath }}>\n        <Layer content={before} alt={beforeLabel} />\n      </motion.div>\n\n      {showLabels && (\n        <>\n          <motion.span aria-hidden=\"true\" className={`${chip} left-3 top-3`} style={{ opacity: beforeOpacity }}>\n            {beforeLabel}\n          </motion.span>\n          <motion.span\n            aria-hidden=\"true\"\n            className={`${chip} ${horizontal ? \"right-3 top-3\" : \"bottom-3 left-3\"}`}\n            style={{ opacity: afterOpacity }}\n          >\n            {afterLabel}\n          </motion.span>\n        </>\n      )}\n\n      {/* Divider: a full-size layer translated by the position (transform only, no layout). */}\n      <motion.div className=\"pointer-events-none absolute inset-0\" style={horizontal ? { x: offset } : { y: offset }}>\n        <span\n          aria-hidden=\"true\"\n          className={`absolute bg-white shadow-[0_0_0_1px_rgb(0_0_0/0.12),0_0_12px_rgb(0_0_0/0.25)] ${\n            horizontal ? \"inset-y-0 left-0 w-0.5 -translate-x-1/2\" : \"inset-x-0 top-0 h-0.5 -translate-y-1/2\"\n          }`}\n        />\n        <div\n          ref={handleRef}\n          role=\"slider\"\n          tabIndex={0}\n          aria-label={handleLabel}\n          aria-orientation={orientation}\n          aria-valuemin={0}\n          aria-valuemax={100}\n          aria-valuenow={Math.round(position ?? initial)}\n          aria-valuetext={valueText(position ?? initial)}\n          onKeyDown={handleKeyDown}\n          className={`pointer-events-auto absolute grid size-10 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full border border-zinc-900/10 bg-white text-zinc-900 shadow-[0_6px_18px_-6px_rgb(0_0_0/0.45)] outline-none transition-[box-shadow] focus-visible:ring-2 focus-visible:ring-[var(--cs-accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-white ${\n            horizontal ? \"left-0 top-1/2\" : \"left-1/2 top-0 rotate-90\"\n          }`}\n        >\n          <svg viewBox=\"0 0 20 20\" fill=\"none\" className=\"size-4\" aria-hidden=\"true\">\n            <path d=\"M7.5 6L3.5 10l4 4M12.5 6l4 4-4 4\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n          </svg>\n          <span aria-hidden=\"true\" className=\"absolute inset-[3px] rounded-full border border-[var(--cs-accent)] opacity-60\" />\n        </div>\n      </motion.div>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "cards"
  ],
  "docs": "Documentation: https://wancraft.my/components/compare-slider"
}
