{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "lens",
  "title": "Lens",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A magnifying lens that follows the pointer over an image or any content, moves with the arrow keys when focused, hides on Escape and keeps the copy inside it out of the accessibility tree.",
  "dependencies": [
    "motion@^12"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/lens.tsx",
      "content": "// Ballmac UI: Lens. https://ui.ballmac.com/components/lens\n\"use client\"\n\nimport * as React from \"react\"\nimport { motion, useReducedMotion } from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useMessages } from \"@/lib/ballmac/i18n\"\n\ntype LensProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** What to magnify: usually an image, but any visual content works. */\n  children: React.ReactNode\n  /** How much the lens enlarges what is under it. */\n  zoom?: number\n  /** Diameter of the lens in pixels. */\n  lensSize?: number\n  /** Accessible name; the keyboard instructions are added for you. */\n  label?: string\n}\n\nconst STEP = 24\n\nfunction Lens({ children, zoom = 2.2, lensSize = 160, label, className, onPointerMove, onPointerLeave, onKeyDown, onFocus, onBlur, ...props }: LensProps) {\n  const msg = useMessages()\n  label ??= msg(\"lens.label\", \"Zoomable image\")\n  const reduce = useReducedMotion()\n  const ref = React.useRef<HTMLDivElement>(null)\n  const [pos, setPos] = React.useState<{ x: number; y: number } | null>(null)\n  const [box, setBox] = React.useState({ w: 0, h: 0 })\n  const [mode, setMode] = React.useState<\"pointer\" | \"keyboard\" | null>(null)\n\n  React.useEffect(() => {\n    const el = ref.current\n    if (!el) return\n    const measure = () => setBox({ w: el.offsetWidth, h: el.offsetHeight })\n    measure()\n    const ro = new ResizeObserver(measure)\n    ro.observe(el)\n    return () => ro.disconnect()\n  }, [])\n\n  const clamp = (x: number, y: number) => ({ x: Math.min(Math.max(x, 0), box.w), y: Math.min(Math.max(y, 0), box.h) })\n\n  return (\n    <div\n      ref={ref}\n      data-slot=\"lens\"\n      role=\"group\"\n      tabIndex={0}\n      aria-label={msg(\"lens.moveThePointerOverIt\", \"{label}. Move the pointer over it, or use the arrow keys, to magnify. Escape hides the lens.\", { label })}\n      onPointerMove={(e) => {\n        onPointerMove?.(e)\n        if (e.pointerType === \"touch\") return\n        const r = e.currentTarget.getBoundingClientRect()\n        setMode(\"pointer\")\n        setPos(clamp(e.clientX - r.left, e.clientY - r.top))\n      }}\n      onPointerLeave={(e) => {\n        onPointerLeave?.(e)\n        if (mode === \"pointer\") {\n          setPos(null)\n          setMode(null)\n        }\n      }}\n      onFocus={(e) => {\n        onFocus?.(e)\n        if (e.currentTarget.matches(\":focus-visible\") && mode !== \"pointer\") {\n          setMode(\"keyboard\")\n          setPos((p) => p ?? { x: box.w / 2, y: box.h / 2 })\n        }\n      }}\n      onBlur={(e) => {\n        onBlur?.(e)\n        if (mode === \"keyboard\") {\n          setPos(null)\n          setMode(null)\n        }\n      }}\n      onKeyDown={(e) => {\n        onKeyDown?.(e)\n        const move: Record<string, [number, number]> = { ArrowLeft: [-STEP, 0], ArrowRight: [STEP, 0], ArrowUp: [0, -STEP], ArrowDown: [0, STEP] }\n        if (e.key === \"Escape\") {\n          setPos(null)\n          setMode(null)\n        } else if (move[e.key]) {\n          e.preventDefault()\n          setMode(\"keyboard\")\n          setPos((p) => {\n            const base = p ?? { x: box.w / 2, y: box.h / 2 }\n            return clamp(base.x + move[e.key]![0], base.y + move[e.key]![1])\n          })\n        }\n      }}\n      className={cn(\"relative inline-block cursor-zoom-in overflow-hidden rounded-xl outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50\", className)}\n      {...props}\n    >\n      {children}\n      {pos && box.w > 0 && (\n        <motion.div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute top-0 start-0 z-10 overflow-hidden rounded-full border-2 border-white/90 bg-background shadow-[0_12px_32px_-6px_rgb(0_0_0/0.45),inset_0_0_0_1px_rgb(0_0_0/0.15)]\"\n          style={{ width: lensSize, height: lensSize, x: pos.x - lensSize / 2, y: pos.y - lensSize / 2 }}\n          initial={reduce ? false : { opacity: 0, scale: 0.7 }}\n          animate={{ opacity: 1, scale: 1 }}\n          transition={{ type: \"spring\", stiffness: 420, damping: 28 }}\n        >\n          {/* A second copy of the content, scaled up around the pointer. It is hidden from assistive tech and cannot be focused. */}\n          <div\n            inert\n            className=\"absolute top-0 left-0 origin-top-left\" // rtl-fixed: scaled from its top-left corner\n            style={{\n              width: box.w,\n              height: box.h,\n              transform: `translate(${lensSize / 2 - pos.x * zoom}px, ${lensSize / 2 - pos.y * zoom}px) scale(${zoom})`,\n            }}\n          >\n            {children}\n          </div>\n          <span className=\"absolute inset-0 rounded-full bg-[radial-gradient(120%_120%_at_30%_15%,rgb(255_255_255/0.28),transparent_45%)]\" />\n        </motion.div>\n      )}\n    </div>\n  )\n}\n\nexport { Lens, type LensProps }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/lens.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "zoom",
      "magnifier",
      "image",
      "hover",
      "lens"
    ],
    "version": "1.0.0",
    "updated": "2026-10-01",
    "ai": {
      "summary": "<Lens zoom lensSize label>{image or content}</Lens>. Works with any child. The magnified copy is inert and aria-hidden. Touch pointers are ignored so scrolling is never blocked.",
      "whenToUse": [
        "Product photos and maps",
        "Inspecting fine detail in diagrams or charts"
      ],
      "whenNotToUse": [
        "Full-screen image viewing (use a dialog)"
      ],
      "composesWith": [
        "card",
        "tilt-card"
      ],
      "a11y": [
        {
          "keys": "Tab",
          "action": "The area is focusable; the lens appears centered"
        },
        {
          "keys": "ArrowKeys",
          "action": "Move the lens in steps; Escape hides it"
        },
        {
          "keys": "Screen readers",
          "action": "The group is named and explains the keys; the copy is hidden"
        }
      ],
      "customization": [
        "zoom",
        "lensSize",
        "label"
      ]
    },
    "examples": [
      "lens-demo",
      "lens-text"
    ],
    "url": "https://ui.ballmac.com/components/lens"
  },
  "categories": [
    "motion"
  ],
  "type": "registry:ui"
}