{
  "name": "spoiler-text",
  "type": "registry:component",
  "dependencies": [],
  "files": [
    {
      "type": "registry:component",
      "content": "\"use client\";\n\n/**\n * @author: @dorianbaffier\n * @description: Spoiler Text\n * @version: 1.0.0\n * @date: 2026-10-16\n * @license: MIT\n * @website: https://kokonutui.com\n * @github: https://github.com/kokonut-labs/kokonutui\n */\n\nimport {\n  type KeyboardEvent,\n  type MouseEvent,\n  type ReactNode,\n  useCallback,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface SpoilerProps {\n  children: ReactNode;\n  /** Controlled state. Leave it out and the spoiler keeps its own. */\n  revealed?: boolean;\n  defaultRevealed?: boolean;\n  onRevealedChange?: (revealed: boolean) => void;\n  /** What screen readers hear while the text is hidden. */\n  label?: string;\n  className?: string;\n}\n\n// Specks per square pixel of hidden text. About one every 6px², like the\n// grain on a scratch card, so even one short word reads as a solid patch.\nconst DENSITY = 1 / 6;\nconst MAX_SPECKS = 2400;\n// How far a speck wanders, in px per second, while it shimmers in place.\nconst DRIFT = 7;\n// The reveal wave crosses the whole spoiler in this long, whatever its size.\nconst WAVE_MS = 520;\n// Once the wave passes a speck it is thrown out and fades over this long.\nconst FADE_MS = 360;\nconst THROW = 70;\n\ninterface Speck {\n  x: number;\n  y: number;\n  vx: number;\n  vy: number;\n  /** Where in its twinkle the speck is, 0 to 1. */\n  phase: number;\n  /** Twinkles per second. */\n  rate: number;\n  size: number;\n  /** The rectangle (one per line) the speck is kept inside. */\n  box: number;\n  /** When the reveal wave reached it, in ms; 0 while hidden. */\n  hit: number;\n}\n\ninterface Box {\n  left: number;\n  top: number;\n  right: number;\n  bottom: number;\n}\n\n/** How far the wave has to travel from where it started to clear it all. */\nfunction farthestCorner(at: { x: number; y: number }, w: number, h: number) {\n  return Math.max(\n    Math.hypot(at.x, at.y),\n    Math.hypot(w - at.x, at.y),\n    Math.hypot(at.x, h - at.y),\n    Math.hypot(w - at.x, h - at.y)\n  );\n}\n\nfunction union(rects: DOMRect[]): Box {\n  return {\n    left: Math.min(...rects.map((r) => r.left)),\n    top: Math.min(...rects.map((r) => r.top)),\n    right: Math.max(...rects.map((r) => r.right)),\n    bottom: Math.max(...rects.map((r) => r.bottom)),\n  };\n}\n\n/**\n * Puts the canvas over the text and returns each line's box inside it.\n * An inline element split over lines has no single box to position a\n * canvas against, and browsers disagree on where its absolute children\n * go. So the canvas is parked at 0,0, measured, then moved by the\n * difference: it lands on the text's bounding box whatever the browser.\n */\nfunction fitCanvas(\n  canvas: HTMLCanvasElement,\n  ctx: CanvasRenderingContext2D,\n  text: HTMLElement\n) {\n  const lines = Array.from(text.getClientRects());\n  if (lines.length === 0) {\n    return null;\n  }\n  const bounds = union(lines);\n  canvas.style.left = \"0px\";\n  canvas.style.top = \"0px\";\n  const origin = canvas.getBoundingClientRect();\n  canvas.style.left = `${bounds.left - origin.left}px`;\n  canvas.style.top = `${bounds.top - origin.top}px`;\n\n  const width = bounds.right - bounds.left;\n  const height = bounds.bottom - bounds.top;\n  const dpr = Math.min(window.devicePixelRatio || 1, 2);\n  canvas.width = Math.round(width * dpr);\n  canvas.height = Math.round(height * dpr);\n  canvas.style.width = `${width}px`;\n  canvas.style.height = `${height}px`;\n  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n\n  const boxes: Box[] = lines.map((r) => ({\n    left: r.left - bounds.left,\n    top: r.top - bounds.top,\n    right: r.right - bounds.left,\n    bottom: r.bottom - bounds.top,\n  }));\n  return { width, height, boxes };\n}\n\n/** Draws every speck still showing; returns how many there were. */\nfunction paint(ctx: CanvasRenderingContext2D, specks: Speck[], frame: Frame) {\n  let alive = 0;\n  for (const s of specks) {\n    const alpha = step(s, frame);\n    if (alpha > 0) {\n      alive++;\n      ctx.globalAlpha = alpha;\n      ctx.fillRect(s.x, s.y, s.size, s.size);\n    }\n  }\n  ctx.globalAlpha = 1;\n  return alive;\n}\n\nconst easeOut = (t: number) => 1 - (1 - t) ** 3;\n\nfunction makeSpecks(boxes: Box[]): Speck[] {\n  const specks: Speck[] = [];\n  const area = boxes.reduce(\n    (sum, box) => sum + (box.right - box.left) * (box.bottom - box.top),\n    0\n  );\n  const scale = Math.min(1, MAX_SPECKS / Math.max(1, area * DENSITY));\n  boxes.forEach((box, index) => {\n    const width = box.right - box.left;\n    const height = box.bottom - box.top;\n    const count = Math.round(width * height * DENSITY * scale);\n    for (let i = 0; i < count; i++) {\n      const angle = Math.random() * Math.PI * 2;\n      const speed = DRIFT * (0.3 + Math.random() * 0.7);\n      specks.push({\n        x: box.left + Math.random() * width,\n        y: box.top + Math.random() * height,\n        vx: Math.cos(angle) * speed,\n        vy: Math.sin(angle) * speed,\n        phase: Math.random(),\n        rate: 0.35 + Math.random() * 0.9,\n        size: Math.random() < 0.18 ? 1.5 : 1,\n        box: index,\n        hit: 0,\n      });\n    }\n  });\n  return specks;\n}\n\ninterface Frame {\n  now: number;\n  dt: number;\n  wave: { x: number; y: number } | null;\n  radius: number;\n  boxes: Box[];\n  reduced: boolean;\n}\n\n/** Moves one speck a frame on; returns its opacity, 0 once it has faded. */\nfunction step(s: Speck, { now, dt, wave, radius, boxes, reduced }: Frame) {\n  if (wave && !s.hit && Math.hypot(s.x - wave.x, s.y - wave.y) <= radius) {\n    s.hit = now;\n    const dx = s.x - wave.x;\n    const dy = s.y - wave.y;\n    const length = Math.hypot(dx, dy) || 1;\n    const push = THROW * (0.5 + Math.random());\n    s.vx += (dx / length) * push;\n    s.vy += (dy / length) * push;\n  }\n  const fade = s.hit ? 1 - (now - s.hit) / FADE_MS : 1;\n  if (fade <= 0) {\n    return 0;\n  }\n  if (!reduced || s.hit) {\n    s.x += s.vx * dt;\n    s.y += s.vy * dt;\n    s.phase = (s.phase + s.rate * dt) % 1;\n  }\n  // Until it's hit, a speck bounces around inside its own line.\n  const box = s.hit ? undefined : boxes[s.box];\n  if (box) {\n    if (s.x < box.left || s.x > box.right) {\n      s.vx = -s.vx;\n      s.x = Math.min(box.right, Math.max(box.left, s.x));\n    }\n    if (s.y < box.top || s.y > box.bottom) {\n      s.vy = -s.vy;\n      s.y = Math.min(box.bottom, Math.max(box.top, s.y));\n    }\n  }\n  // Each speck twinkles between faint and full, out of step with the\n  // others, so the patch shimmers instead of flashing.\n  const twinkle = 0.35 + 0.65 * Math.abs(Math.sin(s.phase * Math.PI));\n  return twinkle * fade * 0.9;\n}\n\n/**\n * Inline text hidden under a field of drifting specks, like a spoiler in a\n * chat app. Click it, or focus it and press Enter, and the specks are blown\n * away from the point you touched while the words come into focus.\n */\nexport function Spoiler({\n  children,\n  revealed: revealedProp,\n  defaultRevealed = false,\n  onRevealedChange,\n  label = \"Hidden text. Activate to reveal.\",\n  className,\n}: SpoilerProps) {\n  const [ownRevealed, setOwnRevealed] = useState(defaultRevealed);\n  const revealed = revealedProp ?? ownRevealed;\n  // The specks stay on screen while the wave clears them, after `revealed`.\n  const [clearing, setClearing] = useState(false);\n  const rootRef = useRef<HTMLSpanElement>(null);\n  const textRef = useRef<HTMLSpanElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const waveRef = useRef<{ x: number; y: number; start: number } | null>(null);\n\n  const reveal = useCallback(\n    (clientX?: number, clientY?: number) => {\n      if (revealed) {\n        return;\n      }\n      const canvas = canvasRef.current;\n      const rect = canvas?.getBoundingClientRect();\n      const first = textRef.current?.getClientRects()[0];\n      if (rect) {\n        // A keyboard reveal starts from the start of the first line.\n        const x = clientX ?? (first ? first.left + 4 : rect.left);\n        const y = clientY ?? (first ? first.top + first.height / 2 : rect.top);\n        waveRef.current = {\n          x: x - rect.left,\n          y: y - rect.top,\n          start: performance.now(),\n        };\n        setClearing(true);\n      }\n      setOwnRevealed(true);\n      onRevealedChange?.(true);\n    },\n    [revealed, onRevealedChange]\n  );\n\n  // Hiding again (a controlled parent can) forgets any wave in progress.\n  useEffect(() => {\n    if (!revealed) {\n      waveRef.current = null;\n      setClearing(false);\n    }\n  }, [revealed]);\n\n  const showSpecks = !revealed || clearing;\n\n  useEffect(() => {\n    if (!showSpecks) {\n      return;\n    }\n    const root = rootRef.current;\n    const text = textRef.current;\n    const canvas = canvasRef.current;\n    const ctx = canvas?.getContext(\"2d\");\n    if (!(root && text && canvas && ctx)) {\n      return;\n    }\n\n    const reduced = window.matchMedia(\n      \"(prefers-reduced-motion: reduce)\"\n    ).matches;\n    let boxes: Box[] = [];\n    let specks: Speck[] = [];\n    let width = 0;\n    let height = 0;\n    let color = \"currentColor\";\n    let frame = 0;\n    let last = 0;\n    let visible = true;\n\n    const layout = () => {\n      const fitted = fitCanvas(canvas, ctx, text);\n      if (!fitted) {\n        return;\n      }\n      ({ width, height, boxes } = fitted);\n      specks = makeSpecks(boxes);\n      color = getComputedStyle(root).color;\n    };\n\n    const draw = (now: number) => {\n      const dt = last ? Math.min(0.05, (now - last) / 1000) : 0;\n      last = now;\n      ctx.clearRect(0, 0, width, height);\n      ctx.fillStyle = color;\n\n      const wave = waveRef.current;\n      const radius = wave\n        ? farthestCorner(wave, width, height) *\n          easeOut(Math.min(1, (now - wave.start) / WAVE_MS))\n        : -1;\n      const alive = paint(ctx, specks, {\n        now,\n        dt,\n        wave,\n        radius,\n        boxes,\n        reduced,\n      });\n\n      if (wave && alive === 0) {\n        waveRef.current = null;\n        setClearing(false);\n        return;\n      }\n      if ((!reduced || wave) && visible) {\n        frame = requestAnimationFrame(draw);\n      }\n    };\n\n    const restart = () => {\n      cancelAnimationFrame(frame);\n      last = 0;\n      frame = requestAnimationFrame(draw);\n    };\n\n    layout();\n    restart();\n\n    // Text rewraps when its container changes width; lay the specks out again.\n    const resize = new ResizeObserver(() => {\n      if (!waveRef.current) {\n        layout();\n        restart();\n      }\n    });\n    const block = root.parentElement ?? root;\n    resize.observe(block);\n\n    // Off screen, the shimmer stops.\n    const seen = new IntersectionObserver(([entry]) => {\n      visible = entry?.isIntersecting ?? true;\n      if (visible) {\n        restart();\n      } else {\n        cancelAnimationFrame(frame);\n      }\n    });\n    seen.observe(root);\n\n    return () => {\n      cancelAnimationFrame(frame);\n      resize.disconnect();\n      seen.disconnect();\n    };\n  }, [showSpecks]);\n\n  const onClick = (event: MouseEvent<HTMLSpanElement>) => {\n    // A keyboard \"click\" has no position; start from the first line instead.\n    if (event.detail === 0) {\n      reveal();\n    } else {\n      reveal(event.clientX, event.clientY);\n    }\n  };\n\n  const onKeyDown = (event: KeyboardEvent<HTMLSpanElement>) => {\n    if (event.key === \"Enter\" || event.key === \" \") {\n      event.preventDefault();\n      reveal();\n    }\n  };\n\n  const text = (\n    <span\n      aria-hidden={revealed ? undefined : true}\n      className={cn(\n        \"transition-[opacity,filter] duration-[450ms] ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-opacity\",\n        revealed ? \"opacity-100 blur-0\" : \"select-none opacity-0 blur-[6px]\"\n      )}\n      ref={textRef}\n    >\n      {children}\n    </span>\n  );\n  const specksLayer = showSpecks && (\n    <canvas className=\"pointer-events-none absolute\" ref={canvasRef} />\n  );\n  const shape =\n    \"relative rounded-[3px] [-webkit-box-decoration-break:clone] [box-decoration-break:clone]\";\n\n  if (revealed) {\n    return (\n      <span className={cn(shape, className)} ref={rootRef}>\n        {text}\n        {specksLayer}\n      </span>\n    );\n  }\n\n  return (\n    // biome-ignore lint/a11y/useSemanticElements: a <button> can't wrap across lines like inline text\n    <span\n      aria-label={label}\n      className={cn(\n        shape,\n        \"cursor-pointer bg-zinc-900/[0.06] outline-none transition-colors duration-150 hover:bg-zinc-900/10 focus-visible:ring-2 focus-visible:ring-zinc-400 dark:bg-white/[0.08] dark:hover:bg-white/12\",\n        className\n      )}\n      onClick={onClick}\n      onKeyDown={onKeyDown}\n      ref={rootRef}\n      role=\"button\"\n      tabIndex={0}\n    >\n      {text}\n      {specksLayer}\n    </span>\n  );\n}\n\ninterface Message {\n  id: string;\n  name: string;\n  initial: string;\n  time: string;\n  body: ReactNode;\n  own?: boolean;\n}\n\n/**\n * A group chat where two messages hide a spoiler. Click a hidden patch to\n * reveal it; \"Hide again\" puts the specks back.\n */\nexport default function SpoilerText({ className }: { className?: string }) {\n  // Remounting the spoilers is the simplest way to hide them all again.\n  const [round, setRound] = useState(0);\n  const [shown, setShown] = useState(0);\n\n  const onReveal = () => setShown((count) => count + 1);\n\n  const messages: Message[] = [\n    {\n      id: \"maya\",\n      name: \"Maya\",\n      initial: \"M\",\n      time: \"21:04\",\n      body: \"Finished the finale last night. I'm still not over it.\",\n    },\n    {\n      id: \"jun\",\n      name: \"Jun\",\n      initial: \"J\",\n      time: \"21:06\",\n      body: (\n        <>\n          Same. When it turned out{\" \"}\n          <Spoiler key={`a${round}`} onRevealedChange={onReveal}>\n            the lighthouse keeper had been writing the letters all along\n          </Spoiler>{\" \"}\n          I had to pause it for a minute.\n        </>\n      ),\n    },\n    {\n      id: \"you\",\n      name: \"You\",\n      initial: \"Y\",\n      time: \"21:07\",\n      own: true,\n      body: (\n        <>\n          Don't. I'm two episodes behind and I know someone{\" \"}\n          <Spoiler key={`b${round}`} onRevealedChange={onReveal}>\n            doesn't make it off the island\n          </Spoiler>{\" \"}\n          already.\n        </>\n      ),\n    },\n  ];\n\n  return (\n    <div\n      className={cn(\n        \"w-full max-w-md rounded-2xl border border-zinc-200 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-950\",\n        className\n      )}\n    >\n      <div className=\"mb-3 flex items-center justify-between\">\n        <p className=\"font-medium text-sm text-zinc-900 dark:text-zinc-100\">\n          # the-finale\n        </p>\n        <button\n          className={cn(\n            \"rounded-md px-2 py-1 text-xs text-zinc-500 transition-[color,background-color,opacity] duration-150 hover:bg-zinc-100 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-900 dark:hover:text-zinc-100\",\n            shown === 0 && \"pointer-events-none opacity-0\"\n          )}\n          onClick={() => {\n            setRound((r) => r + 1);\n            setShown(0);\n          }}\n          tabIndex={shown === 0 ? -1 : undefined}\n          type=\"button\"\n        >\n          Hide again\n        </button>\n      </div>\n      <ul className=\"flex flex-col gap-3\">\n        {messages.map((message) => (\n          <li className=\"flex gap-2.5\" key={message.id}>\n            <span\n              aria-hidden=\"true\"\n              className={cn(\n                \"mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full font-medium text-xs\",\n                message.own\n                  ? \"bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900\"\n                  : \"bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300\"\n              )}\n            >\n              {message.initial}\n            </span>\n            <div className=\"min-w-0\">\n              <p className=\"flex items-baseline gap-2\">\n                <span className=\"font-medium text-sm text-zinc-900 dark:text-zinc-100\">\n                  {message.name}\n                </span>\n                <span className=\"text-xs text-zinc-400 tabular-nums dark:text-zinc-500\">\n                  {message.time}\n                </span>\n              </p>\n              <p className=\"text-[15px] text-zinc-700 leading-6 dark:text-zinc-300\">\n                {message.body}\n              </p>\n            </div>\n          </li>\n        ))}\n      </ul>\n    </div>\n  );\n}\n",
      "path": "/components/kokonutui/spoiler-text.tsx",
      "target": "components/kokonutui/spoiler-text.tsx"
    }
  ]
}