{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "flashlight-reveal",
  "type": "registry:ui",
  "title": "Flashlight Reveal",
  "description": "A dark layer with a soft, eased flashlight that reveals the content beneath the pointer.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/flashlight-reveal.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport type FlashlightTouchBehavior = \"follow\" | \"reveal\";\n\nexport interface FlashlightRevealProps extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"children\"> {\n  /** Content revealed by the light. Always rendered and always available to assistive tech. */\n  children: React.ReactNode;\n  /** Optional decorative content drawn on the dark layer (hidden from assistive tech). */\n  cover?: React.ReactNode;\n  /** Radius of the light, in px. */\n  radius?: number;\n  /** Edge softness from 0 (hard edge) to 1 (fully feathered). */\n  softness?: number;\n  /** Follow lag from 0 (instant) to 0.95 (very floaty). */\n  smoothing?: number;\n  /** Opacity of the dark layer. */\n  darkness?: number;\n  /** Colour of the dark layer. */\n  overlayColor?: string;\n  /** Warm tint inside the light. */\n  glow?: string;\n  /** Show the \"Reveal all\" toggle (the keyboard and touch alternative). */\n  showToggle?: boolean;\n  /** Touch: the light follows the finger, or a tap reveals everything. */\n  touchBehavior?: FlashlightTouchBehavior;\n  /** Controlled full reveal. */\n  revealed?: boolean;\n  /** Initial full reveal (uncontrolled). */\n  defaultRevealed?: boolean;\n  onRevealedChange?: (revealed: boolean) => void;\n  /** Visible label of the toggle. */\n  toggleLabel?: string;\n}\n\n/* ----------------------------- Reduced motion ------------------------------ */\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\n/* -------------------------------- Component -------------------------------- */\n\nconst MASK =\n  \"radial-gradient(circle at var(--fr-x) var(--fr-y), transparent calc(var(--fr-r) * var(--fr-inner)), #000 var(--fr-r))\";\n\n/**\n * A dark layer with a soft circular light that follows the pointer and reveals the content\n * beneath. The reveal is purely visual: the content is ordinary DOM underneath an\n * aria-hidden, pointer-transparent overlay, so it stays readable, focusable and clickable.\n * Position and radius are eased in a short rAF loop that stops as soon as the light settles,\n * and are written to CSS variables (no re-renders while moving).\n */\nexport function FlashlightReveal({\n  children,\n  cover,\n  radius = 150,\n  softness = 0.6,\n  smoothing = 0.8,\n  darkness = 0.94,\n  overlayColor = \"#0A0B0D\",\n  glow = \"#FF7448\",\n  showToggle = true,\n  touchBehavior = \"follow\",\n  revealed: revealedProp,\n  defaultRevealed = false,\n  onRevealedChange,\n  toggleLabel = \"Reveal all\",\n  className = \"\",\n  style,\n  ...props\n}: FlashlightRevealProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const [internal, setInternal] = React.useState(defaultRevealed);\n  const revealed = revealedProp ?? internal;\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const contentRef = React.useRef<HTMLDivElement>(null);\n  // Light state survives effect re-runs (e.g. toggling `revealed`), so changes animate from where it is.\n  const lightRef = React.useRef({ x: 0, y: 0, r: 0, tx: 0, ty: 0, tr: 0, w: 0, h: 0, lit: false });\n\n  const setRevealed = (next: boolean) => {\n    if (next === revealed) return;\n    if (revealedProp === undefined) setInternal(next);\n    onRevealedChange?.(next);\n  };\n\n  // The touch \"reveal\" behaviour needs the latest setter inside the long-lived effect below.\n  const revealRef = React.useRef(setRevealed);\n  React.useEffect(() => {\n    revealRef.current = setRevealed;\n  });\n\n  React.useEffect(() => {\n    const root = rootRef.current;\n    const content = contentRef.current;\n    if (!root || !content) return;\n\n    const s = lightRef.current; // `s.lit`: pointer, touch or focus currently holds the light\n    let raf = 0;\n    let last = 0;\n    const fullRadius = () => Math.hypot(s.w, s.h) + radius;\n\n    const write = () => {\n      root.style.setProperty(\"--fr-x\", `${s.x.toFixed(1)}px`);\n      root.style.setProperty(\"--fr-y\", `${s.y.toFixed(1)}px`);\n      root.style.setProperty(\"--fr-r\", `${Math.max(0, s.r).toFixed(1)}px`);\n    };\n\n    const frame = (now: number) => {\n      raf = 0;\n      const dt = Math.min(64, last ? now - last : 16.7);\n      last = now;\n      const lag = Math.min(0.95, Math.max(0, smoothing));\n      const a = reduceMotion ? 1 : 1 - Math.pow(lag, dt / 16.7);\n      s.x += (s.tx - s.x) * a;\n      s.y += (s.ty - s.y) * a;\n      s.r += (s.tr - s.r) * a;\n      const settled = Math.abs(s.tx - s.x) < 0.3 && Math.abs(s.ty - s.y) < 0.3 && Math.abs(s.tr - s.r) < 0.3;\n      if (settled) {\n        s.x = s.tx;\n        s.y = s.ty;\n        s.r = s.tr;\n      }\n      write();\n      if (!settled && !document.hidden) raf = requestAnimationFrame(frame);\n      else last = 0;\n    };\n    const kick = () => {\n      if (!raf) raf = requestAnimationFrame(frame);\n    };\n\n    const aimRadius = () => {\n      s.tr = revealed ? fullRadius() : s.lit ? radius : 0;\n    };\n\n    const measure = () => {\n      const rect = root.getBoundingClientRect();\n      const first = s.w === 0;\n      s.w = rect.width;\n      s.h = rect.height;\n      if (first) {\n        s.x = s.tx = s.w / 2;\n        s.y = s.ty = s.h / 2;\n        s.r = revealed ? fullRadius() : 0;\n      }\n      aimRadius();\n      kick();\n    };\n\n    const moveTo = (clientX: number, clientY: number) => {\n      const rect = root.getBoundingClientRect();\n      s.tx = clientX - rect.left;\n      s.ty = clientY - rect.top;\n      if (!s.lit) {\n        // Entering: start the light where the pointer is instead of sweeping across.\n        s.lit = true;\n        if (s.r < 1) {\n          s.x = s.tx;\n          s.y = s.ty;\n        }\n      }\n      aimRadius();\n      kick();\n    };\n\n    const onPointerMove = (e: PointerEvent) => {\n      if (e.pointerType === \"touch\" && touchBehavior !== \"follow\") return;\n      moveTo(e.clientX, e.clientY);\n    };\n    const onPointerDown = (e: PointerEvent) => {\n      if (e.pointerType !== \"touch\") return;\n      if (touchBehavior === \"reveal\") revealRef.current(true);\n      else moveTo(e.clientX, e.clientY);\n    };\n    const onPointerLeave = (e: PointerEvent) => {\n      // On touch the light stays where the finger lifted.\n      if (e.pointerType === \"touch\") return;\n      s.lit = false;\n      aimRadius();\n      kick();\n    };\n\n    // Keyboard users: the light follows focus within the revealed content.\n    const onFocusIn = (e: FocusEvent) => {\n      const target = e.target;\n      if (!(target instanceof HTMLElement) || !target.matches(\":focus-visible\")) return;\n      const rect = target.getBoundingClientRect();\n      const host = root.getBoundingClientRect();\n      s.tx = rect.left + rect.width / 2 - host.left;\n      s.ty = rect.top + rect.height / 2 - host.top;\n      s.lit = true;\n      s.tr = revealed ? fullRadius() : Math.max(radius, Math.hypot(rect.width, rect.height) / 2 + 28);\n      kick();\n    };\n    const onFocusOut = (e: FocusEvent) => {\n      if (e.relatedTarget instanceof Node && content.contains(e.relatedTarget)) return;\n      s.lit = false;\n      aimRadius();\n      kick();\n    };\n    const onVisibility = () => {\n      if (!document.hidden) kick();\n    };\n\n    measure();\n    const ro = new ResizeObserver(measure);\n    ro.observe(root);\n    root.addEventListener(\"pointermove\", onPointerMove, { passive: true });\n    root.addEventListener(\"pointerdown\", onPointerDown, { passive: true });\n    root.addEventListener(\"pointerleave\", onPointerLeave);\n    content.addEventListener(\"focusin\", onFocusIn);\n    content.addEventListener(\"focusout\", onFocusOut);\n    document.addEventListener(\"visibilitychange\", onVisibility);\n    return () => {\n      cancelAnimationFrame(raf);\n      ro.disconnect();\n      root.removeEventListener(\"pointermove\", onPointerMove);\n      root.removeEventListener(\"pointerdown\", onPointerDown);\n      root.removeEventListener(\"pointerleave\", onPointerLeave);\n      content.removeEventListener(\"focusin\", onFocusIn);\n      content.removeEventListener(\"focusout\", onFocusOut);\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n    };\n  }, [radius, smoothing, touchBehavior, revealed, reduceMotion]);\n\n  const inner = 1 - Math.min(1, Math.max(0, softness));\n\n  return (\n    <div\n      ref={rootRef}\n      className={`absolute inset-0 isolate overflow-hidden [--fr-r:0px] [--fr-x:50%] [--fr-y:50%] ${className}`}\n      style={\n        {\n          \"--fr-inner\": inner.toFixed(3),\n          \"--fr-glow\": glow,\n          ...style,\n        } as React.CSSProperties\n      }\n      {...props}\n    >\n      <div ref={contentRef} className=\"relative h-full w-full\">\n        {children}\n      </div>\n\n      {/* Dark layer with a hole cut by the mask. Visual only: hidden from AT, transparent to pointers. */}\n      <div\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-0\"\n        style={{ maskImage: MASK, WebkitMaskImage: MASK }}\n      >\n        <div className=\"absolute inset-0\" style={{ background: overlayColor, opacity: darkness }} />\n        {cover != null && <div className=\"absolute inset-0\">{cover}</div>}\n      </div>\n\n      {/* Soft warm tint inside the light. */}\n      <div\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-0 mix-blend-soft-light transition-opacity duration-500 motion-reduce:transition-none\"\n        style={{\n          background:\n            \"radial-gradient(circle at var(--fr-x) var(--fr-y), color-mix(in srgb, var(--fr-glow) 55%, transparent) 0, transparent var(--fr-r))\",\n          opacity: revealed ? 0 : 1,\n        }}\n      />\n\n      {showToggle && (\n        <button\n          type=\"button\"\n          aria-pressed={revealed}\n          onClick={() => setRevealed(!revealed)}\n          className=\"absolute bottom-3 right-3 z-10 inline-flex h-8 items-center gap-2 rounded-full border border-white/15 bg-zinc-950/70 px-3 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-200 backdrop-blur-sm transition-colors hover:border-white/35 hover:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--fr-glow)] aria-pressed:border-[var(--fr-glow)] aria-pressed:text-white\"\n        >\n          <svg aria-hidden=\"true\" viewBox=\"0 0 16 16\" className=\"h-3.5 w-3.5\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.4\">\n            <path d=\"M6 13.5h4M6.5 11.5h3M8 1.5a4.5 4.5 0 0 0-2.6 8.2c.4.3.6.7.6 1.1v.7h4v-.7c0-.4.2-.8.6-1.1A4.5 4.5 0 0 0 8 1.5Z\" />\n          </svg>\n          {toggleLabel}\n        </button>\n      )}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "backgrounds"
  ],
  "docs": "Documentation: https://wancraft.my/components/flashlight-reveal"
}
