{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "spoiler-text",
  "type": "registry:ui",
  "title": "Spoiler Text",
  "description": "Text hidden under shimmering particle noise that dissolves outward from where you click.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/spoiler-text.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\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\n/** SSR-safe: hydrates as \"no preference\", then follows the media query. */\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nlet probe: CanvasRenderingContext2D | null = null;\n\n/** Resolves any CSS colour (hex, rgb(), oklch()…) to RGB by painting it on a 1×1 canvas. */\nfunction toRgb(input: string, fallback: [number, number, number]): [number, number, number] {\n  probe ??= document.createElement(\"canvas\").getContext(\"2d\", { willReadFrequently: true });\n  if (!probe) return fallback;\n  probe.clearRect(0, 0, 1, 1);\n  probe.fillStyle = \"rgba(0, 0, 0, 0)\";\n  probe.fillStyle = input;\n  probe.fillRect(0, 0, 1, 1);\n  const [r, g, b, a] = probe.getImageData(0, 0, 1, 1).data;\n  return a === 0 ? fallback : [r, g, b];\n}\n\nexport type SpoilerTextMode = \"inline\" | \"block\";\n\nexport interface SpoilerTextProps extends Omit<React.HTMLAttributes<HTMLElement>, \"children\" | \"onChange\"> {\n  /** The hidden content. Inline mode expects phrasing content; block mode accepts paragraphs. */\n  children: React.ReactNode;\n  /** `inline` flows inside a sentence (multi-line aware); `block` covers a whole panel. */\n  mode?: SpoilerTextMode;\n  /** Controlled revealed state. */\n  revealed?: boolean;\n  /** Initial revealed state when uncontrolled. */\n  defaultRevealed?: boolean;\n  /** Called when the spoiler is revealed or hidden again. */\n  onRevealedChange?: (revealed: boolean) => void;\n  /** Allow clicking a revealed spoiler to cover it again. When false the spoiler becomes plain text. */\n  rehide?: boolean;\n  /** Particle density multiplier (1 ≈ one particle per 12px²). */\n  density?: number;\n  /** Drift speed multiplier for the shimmering particles. 0 freezes them. */\n  speed?: number;\n  /** Duration of the outward dissolve, in ms. */\n  revealDuration?: number;\n  /** Focus ring and spark colour (any CSS colour). */\n  accent?: string;\n  /** Accessible name of the button while the content is hidden. */\n  label?: string;\n  /** Small caption shown over a hidden block spoiler. */\n  hint?: string;\n}\n\ntype Particle = {\n  /** Home rect index and position inside the canvas, in CSS px. */\n  r: number;\n  x: number;\n  y: number;\n  vx: number;\n  vy: number;\n  size: number;\n  phase: number;\n  twinkle: number;\n  spark: boolean;\n};\n\ntype Rect = { x: number; y: number; w: number; h: number };\ntype Phase = \"hidden\" | \"revealing\" | \"revealed\" | \"hiding\";\n\nconst HIDE_MS = 320;\n\n/**\n * Text hidden under shimmering particle noise. Clicking (or Enter/Space) dissolves the noise\n * outward from the press point. Inline spoilers follow every line box via getClientRects, so\n * they wrap naturally inside paragraphs. Until revealed, the content is aria-hidden and the\n * element is a button named \"Reveal spoiler\".\n */\nexport function SpoilerText({\n  children,\n  mode = \"inline\",\n  revealed: revealedProp,\n  defaultRevealed = false,\n  onRevealedChange,\n  rehide = true,\n  density = 1,\n  speed = 1,\n  revealDuration = 650,\n  accent = \"#FF7448\",\n  label = \"Reveal spoiler\",\n  hint = \"Spoiler · click to reveal\",\n  className = \"\",\n  style,\n  onClick,\n  onKeyDown,\n  ...props\n}: SpoilerTextProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const [inner, setInner] = React.useState(defaultRevealed);\n  const revealed = revealedProp ?? inner;\n  const block = mode === \"block\";\n  const interactive = !revealed || rehide;\n\n  const rootRef = React.useRef<HTMLElement | null>(null);\n  const textRef = React.useRef<HTMLElement | null>(null);\n  const canvasRef = React.useRef<HTMLCanvasElement>(null);\n\n  /** Mutable animation state shared by the effect and the event handlers (never read in render). */\n  const anim = React.useRef({\n    phase: (defaultRevealed ? \"revealed\" : \"hidden\") as Phase,\n    start: 0,\n    ox: 0,\n    oy: 0,\n    maxDist: 1,\n    kick: () => {},\n  });\n\n  const setRoot = (node: HTMLElement | null) => {\n    rootRef.current = node;\n  };\n  const setText = (node: HTMLElement | null) => {\n    textRef.current = node;\n  };\n\n  const commit = (next: boolean) => {\n    if (revealedProp === undefined) setInner(next);\n    onRevealedChange?.(next);\n  };\n\n  /** Starts the dissolve from a client-space point (or the centre for keyboard activation). */\n  const reveal = (clientX?: number, clientY?: number) => {\n    const root = rootRef.current;\n    const canvas = canvasRef.current;\n    const a = anim.current;\n    if (!root || !canvas) return;\n    const box = canvas.getBoundingClientRect();\n    const bounds = root.getBoundingClientRect();\n    const cx = clientX ?? bounds.left + bounds.width / 2;\n    const cy = clientY ?? bounds.top + bounds.height / 2;\n    a.ox = cx - box.left;\n    a.oy = cy - box.top;\n    a.maxDist = Math.max(\n      Math.hypot(a.ox, a.oy),\n      Math.hypot(box.width - a.ox, a.oy),\n      Math.hypot(a.ox, box.height - a.oy),\n      Math.hypot(box.width - a.ox, box.height - a.oy),\n      1,\n    );\n    a.start = performance.now();\n    a.phase = reduceMotion || revealDuration === 0 ? \"revealed\" : \"revealing\";\n\n    // Block mode: the text is unveiled by a radial mask that grows with the wave.\n    const text = textRef.current;\n    if (block && text && a.phase === \"revealing\") {\n      const tb = text.getBoundingClientRect();\n      text.style.setProperty(\"--sp-x\", `${Math.round(cx - tb.left)}px`);\n      text.style.setProperty(\"--sp-y\", `${Math.round(cy - tb.top)}px`);\n      text.style.setProperty(\"--sp-r\", \"0px\");\n      const mask = \"radial-gradient(circle at var(--sp-x) var(--sp-y), #000 var(--sp-r), transparent calc(var(--sp-r) + 48px))\";\n      text.style.maskImage = mask;\n      text.style.webkitMaskImage = mask;\n    }\n    a.kick();\n    commit(true);\n  };\n\n  const hide = () => {\n    const a = anim.current;\n    a.start = performance.now();\n    a.phase = reduceMotion ? \"hidden\" : \"hiding\";\n    a.kick();\n    commit(false);\n  };\n\n  // Keep the animation phase in step with controlled changes made outside the component.\n  React.useEffect(() => {\n    const a = anim.current;\n    if (revealed && (a.phase === \"hidden\" || a.phase === \"hiding\")) {\n      a.phase = \"revealed\";\n      a.kick();\n    } else if (!revealed && (a.phase === \"revealed\" || a.phase === \"revealing\")) {\n      a.phase = \"hidden\";\n      a.kick();\n    }\n  }, [revealed]);\n\n  /* ---------------------------------- canvas --------------------------------- */\n\n  React.useEffect(() => {\n    const root = rootRef.current;\n    const canvas = canvasRef.current;\n    const ctx = canvas?.getContext(\"2d\");\n    if (!root || !canvas || !ctx) return;\n\n    const a = anim.current;\n    let particles: Particle[] = [];\n    let rects: Rect[] = [];\n    let width = 0;\n    let height = 0;\n    let dpr = 1;\n    let ink: [number, number, number] = [113, 113, 122];\n    let spark: [number, number, number] = [255, 116, 72];\n    let raf = 0;\n    let last = 0;\n    let lastColour = 0;\n    let onScreen = true;\n\n    const resolveColours = () => {\n      const cs = getComputedStyle(root);\n      ink = toRgb(cs.color, ink);\n      spark = toRgb(cs.getPropertyValue(\"--sp-accent\").trim() || accent, spark);\n    };\n\n    /** Places the canvas over the union of the line boxes and seeds particles per line. */\n    const measure = () => {\n      const lines = Array.from(root.getClientRects()).filter((r) => r.width > 0.5 && r.height > 0.5);\n      if (!lines.length) return;\n      canvas.style.left = \"0px\";\n      canvas.style.top = \"0px\";\n      const origin = canvas.getBoundingClientRect();\n      const left = Math.min(...lines.map((r) => r.left));\n      const top = Math.min(...lines.map((r) => r.top));\n      width = Math.max(...lines.map((r) => r.right)) - left;\n      height = Math.max(...lines.map((r) => r.bottom)) - top;\n      canvas.style.left = `${Math.round((left - origin.left) * 100) / 100}px`;\n      canvas.style.top = `${Math.round((top - origin.top) * 100) / 100}px`;\n      canvas.style.width = `${width}px`;\n      canvas.style.height = `${height}px`;\n      dpr = Math.min(window.devicePixelRatio || 1, 2);\n      canvas.width = Math.max(1, Math.round(width * dpr));\n      canvas.height = Math.max(1, Math.round(height * dpr));\n      rects = lines.map((r) => ({ x: r.left - left, y: r.top - top, w: r.width, h: r.height }));\n\n      particles = [];\n      const per = 12 / Math.max(0.1, density);\n      rects.forEach((rect, ri) => {\n        const count = Math.min(2500, Math.round((rect.w * rect.h) / per));\n        for (let i = 0; i < count; i++) {\n          const angle = Math.random() * Math.PI * 2;\n          const v = 4 + Math.random() * 10;\n          particles.push({\n            r: ri,\n            x: rect.x + Math.random() * rect.w,\n            y: rect.y + Math.random() * rect.h,\n            vx: Math.cos(angle) * v,\n            vy: Math.sin(angle) * v,\n            size: Math.random() < 0.25 ? 1.6 : 1.1,\n            phase: Math.random() * Math.PI * 2,\n            twinkle: 1.5 + Math.random() * 3,\n            spark: Math.random() < 0.08,\n          });\n        }\n      });\n      resolveColours();\n      draw(performance.now(), 0);\n    };\n\n    const draw = (now: number, dt: number) => {\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      ctx.clearRect(0, 0, width, height);\n      if (a.phase === \"revealed\") return;\n\n      const t = now / 1000;\n      const animated = !reduceMotion && speed > 0;\n      let wave = -1;\n      let band = 1;\n      let fadeIn = 1;\n      if (a.phase === \"revealing\") {\n        const p = Math.min(1, (now - a.start) / revealDuration);\n        band = a.maxDist * 0.35;\n        wave = (1 - Math.pow(1 - p, 2)) * (a.maxDist + band);\n        const text = textRef.current;\n        if (block && text) text.style.setProperty(\"--sp-r\", `${Math.round(wave)}px`);\n      } else if (a.phase === \"hiding\") {\n        fadeIn = Math.min(1, (now - a.start) / HIDE_MS);\n      }\n\n      for (const p of particles) {\n        const rect = rects[p.r];\n        if (!rect) continue;\n        if (animated && a.phase === \"hidden\") {\n          p.x += p.vx * speed * dt;\n          p.y += p.vy * speed * dt;\n          // Wrap inside the particle's own line box so inline spoilers never bleed between lines.\n          if (p.x < rect.x) p.x += rect.w;\n          else if (p.x > rect.x + rect.w) p.x -= rect.w;\n          if (p.y < rect.y) p.y += rect.h;\n          else if (p.y > rect.y + rect.h) p.y -= rect.h;\n        }\n        let alpha = animated ? 0.35 + 0.65 * (0.5 + 0.5 * Math.sin(t * p.twinkle * speed + p.phase)) : 0.4 + 0.6 * ((p.phase / Math.PI) % 1);\n        let x = p.x;\n        let y = p.y;\n        if (wave >= 0) {\n          const dx = p.x - a.ox;\n          const dy = p.y - a.oy;\n          const dist = Math.hypot(dx, dy) || 1;\n          const local = Math.min(1, Math.max(0, (wave - dist) / band));\n          if (local >= 1) continue;\n          alpha *= 1 - local;\n          // Particles inside the wave are flung outward as they fade.\n          const push = local * local * 22;\n          x += (dx / dist) * push;\n          y += (dy / dist) * push;\n        }\n        alpha *= fadeIn;\n        const [r, g, b] = p.spark ? spark : ink;\n        ctx.fillStyle = `rgba(${r},${g},${b},${alpha.toFixed(3)})`;\n        ctx.fillRect(x, y, p.size, p.size);\n      }\n    };\n\n    const running = () =>\n      onScreen && !document.hidden && (a.phase === \"revealing\" || a.phase === \"hiding\" || (a.phase === \"hidden\" && !reduceMotion && speed > 0));\n\n    const tick = (now: number) => {\n      raf = 0;\n      const dt = last ? Math.min(0.05, (now - last) / 1000) : 0;\n      last = now;\n      if (now - lastColour > 600) {\n        lastColour = now;\n        resolveColours(); // follows theme switches without observers\n      }\n      if (a.phase === \"revealing\" && now - a.start >= revealDuration) {\n        a.phase = \"revealed\";\n        const text = textRef.current;\n        if (text) {\n          text.style.maskImage = \"\";\n          text.style.webkitMaskImage = \"\";\n        }\n      } else if (a.phase === \"hiding\" && now - a.start >= HIDE_MS) {\n        a.phase = \"hidden\";\n      }\n      draw(now, dt);\n      if (running()) raf = requestAnimationFrame(tick);\n    };\n\n    /** (Re)starts the loop when needed, or paints a single frame for static states. */\n    const kick = () => {\n      if (a.phase === \"revealed\") {\n        const text = textRef.current;\n        if (text) {\n          text.style.maskImage = \"\";\n          text.style.webkitMaskImage = \"\";\n        }\n      }\n      if (running()) {\n        if (!raf) {\n          last = 0;\n          raf = requestAnimationFrame(tick);\n        }\n      } else {\n        cancelAnimationFrame(raf);\n        raf = 0;\n        draw(performance.now(), 0);\n      }\n    };\n    a.kick = kick;\n\n    measure();\n    kick();\n\n    // Inline elements don't report sizes to ResizeObserver, so watch the parent block instead.\n    const ro = new ResizeObserver(() => {\n      measure();\n      kick();\n    });\n    ro.observe(root.parentElement ?? root);\n    if (block) ro.observe(root);\n    const io = new IntersectionObserver(([entry]) => {\n      onScreen = entry.isIntersecting;\n      kick();\n    });\n    io.observe(root);\n    const onVisibility = () => kick();\n    document.addEventListener(\"visibilitychange\", onVisibility);\n    let disposed = false;\n    document.fonts?.ready.then(() => {\n      if (!disposed) {\n        measure();\n        kick();\n      }\n    });\n\n    return () => {\n      disposed = true;\n      cancelAnimationFrame(raf);\n      ro.disconnect();\n      io.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n      a.kick = () => {};\n    };\n  }, [block, density, speed, revealDuration, reduceMotion, accent]);\n\n  /* --------------------------------- events --------------------------------- */\n\n  const handleClick = (event: React.MouseEvent<HTMLElement>) => {\n    onClick?.(event);\n    if (event.defaultPrevented) return;\n    if (!revealed) {\n      // detail === 0 means a keyboard-synthesised click: dissolve from the centre.\n      if (event.detail === 0) reveal();\n      else reveal(event.clientX, event.clientY);\n    } else if (rehide) {\n      // Don't cover the text again while the reader is selecting it.\n      const selection = window.getSelection();\n      if (selection && !selection.isCollapsed) return;\n      hide();\n    }\n  };\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {\n    onKeyDown?.(event);\n    if (event.defaultPrevented || !interactive || event.target !== event.currentTarget) return;\n    if (event.key === \"Enter\" || event.key === \" \") {\n      event.preventDefault();\n      if (!revealed) reveal();\n      else hide();\n    }\n  };\n\n  /* --------------------------------- render --------------------------------- */\n\n  const fade = reduceMotion ? \"none\" : block ? \"opacity 160ms ease-out\" : `opacity ${Math.round(revealDuration * 0.85)}ms cubic-bezier(0.2, 0.7, 0.2, 1) ${Math.round(revealDuration * 0.1)}ms`;\n\n  const shared = {\n    ref: setRoot,\n    ...props,\n    role: interactive ? \"button\" : undefined,\n    tabIndex: interactive ? 0 : -1,\n    \"aria-expanded\": interactive ? revealed : undefined,\n    \"aria-label\": revealed ? props[\"aria-label\"] : label,\n    \"data-state\": revealed ? \"revealed\" : \"hidden\",\n    onClick: handleClick,\n    onKeyDown: handleKeyDown,\n    style: { [\"--sp-accent\" as string]: accent, ...style },\n  };\n\n  const canvas = (\n    <canvas\n      ref={canvasRef}\n      aria-hidden=\"true\"\n      className=\"pointer-events-none absolute left-0 top-0\"\n    />\n  );\n\n  const ring =\n    \"outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--sp-accent)]\";\n\n  if (block) {\n    return (\n      <div\n        {...shared}\n        className={`group/spoiler relative block overflow-hidden rounded-xl border p-5 text-left transition-[background-color,border-color] duration-300 motion-reduce:transition-none ${\n          revealed\n            ? \"border-zinc-900/10 bg-transparent dark:border-white/10\"\n            : \"cursor-pointer border-zinc-900/10 bg-zinc-900/[0.04] hover:bg-zinc-900/[0.06] dark:border-white/10 dark:bg-white/[0.04] dark:hover:bg-white/[0.06]\"\n        } ${revealed && rehide ? \"cursor-pointer\" : \"\"} ${interactive ? ring : \"\"} ${className}`}\n      >\n        <div\n          ref={setText}\n          aria-hidden={revealed ? undefined : true}\n          className={revealed ? \"\" : \"select-none\"}\n          style={{ opacity: revealed ? 1 : 0, transition: fade }}\n        >\n          {children}\n        </div>\n        {canvas}\n        {hint && (\n          <span\n            aria-hidden=\"true\"\n            className={`pointer-events-none absolute inset-0 grid place-items-center transition-opacity duration-200 motion-reduce:transition-none ${\n              revealed ? \"opacity-0\" : \"opacity-100\"\n            }`}\n          >\n            <span className=\"rounded-full border border-zinc-900/10 bg-white/90 px-3 py-1 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-600 shadow-[0_6px_18px_-10px_rgba(0,0,0,0.35)] dark:border-white/10 dark:bg-zinc-900/90 dark:text-zinc-300\">\n              {hint}\n            </span>\n          </span>\n        )}\n      </div>\n    );\n  }\n\n  return (\n    <span\n      {...shared}\n      className={`relative rounded-[5px] px-[3px] [box-decoration-break:clone] [-webkit-box-decoration-break:clone] transition-[background-color] duration-300 motion-reduce:transition-none ${\n        revealed\n          ? \"bg-transparent\"\n          : \"cursor-pointer bg-zinc-900/[0.07] hover:bg-zinc-900/[0.1] dark:bg-white/[0.08] dark:hover:bg-white/[0.12]\"\n      } ${revealed && rehide ? \"cursor-pointer\" : \"\"} ${interactive ? ring : \"\"} ${className}`}\n    >\n      <span\n        ref={setText}\n        aria-hidden={revealed ? undefined : true}\n        className={revealed ? \"\" : \"select-none\"}\n        style={{ opacity: revealed ? 1 : 0, transition: fade }}\n      >\n        {children}\n      </span>\n      {canvas}\n    </span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "text"
  ],
  "docs": "Documentation: https://wancraft.my/components/spoiler-text"
}
