{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"spoiler-text","type":"registry:component","title":"Spoiler text","description":"Hides a spoiler under shimmering grain that blows away from your click.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":[],"registryDependencies":["utils"],"files":[{"path":"components/spoiler-text.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useRef, useState, type KeyboardEvent, type MouseEvent } from \"react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\nimport { usePreviewPlay } from \"@/lib/preview-play\";\n\n// How long the dissolve takes to travel from the click to the far edge,\n// then how long each grain takes to fade once reached. 750ms in all is long\n// for UI on purpose: it's a one-off payoff, and it never blocks reading.\nconst SPREAD_MS = 450;\nconst GRAIN_FADE_MS = 300;\nconst COVER_MS = 200;\n// Grains per square pixel: dense enough to hide letter shapes, sparse\n// enough to read as shimmer rather than a grey bar.\nconst DENSITY = 0.15;\nconst MAX_GRAINS = 1200;\n\ntype Rect = { x: number; y: number; w: number; h: number };\ntype Grain = { x: number; y: number; a: number; phase: number; speed: number; size: number };\ntype Phase = \"hidden\" | \"dissolving\" | \"revealed\" | \"covering\";\n\n// Client rects are in screen pixels, but the canvas is drawn inside any\n// CSS scale an ancestor applies (the index cards shrink demos), so every\n// measurement is divided by that scale or it would be applied twice.\nfunction screenScale(el: HTMLElement) {\n  const s = el.getBoundingClientRect().width / el.offsetWidth;\n  return Number.isFinite(s) && s > 0 ? s : 1;\n}\n\nexport function Spoiler({\n  children,\n  revealed: revealedProp,\n  className,\n}: {\n  children: string;\n  /**\n   * Reveals or covers the spoiler from outside, dissolving from the middle\n   * of its first line. Leave undefined to let readers reveal it themselves.\n   */\n  revealed?: boolean;\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  const [revealed, setRevealed] = useState(false);\n  const [chipOpen, setChipOpen] = useState(false);\n  // The hide chip waits for the dissolve to finish, so it never pops up\n  // over the payoff itself.\n  const [settled, setSettled] = useState(false);\n  const settleTimer = useRef<ReturnType<typeof setTimeout>>(undefined);\n  const [chipPos, setChipPos] = useState<{ left: number; top: number } | null>(null);\n\n  const probeRef = useRef<HTMLSpanElement>(null);\n  const textRef = useRef<HTMLSpanElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const chipRef = useRef<HTMLButtonElement>(null);\n  // Everything the draw loop reads lives in one ref, so the loop never\n  // needs React state and never re-renders per frame.\n  const engine = useRef({\n    phase: \"hidden\" as Phase,\n    phaseStart: 0,\n    origin: { x: 0, y: 0 },\n    maxDistance: 1,\n    grains: [] as Grain[],\n    rects: [] as Rect[],\n    width: 0,\n    height: 0,\n    scale: 1,\n    // The grain's own clock, which only advances while it shimmers, so a\n    // paused shimmer resumes where it stopped instead of jumping.\n    time: 0,\n    last: 0,\n    // Idle index cards hold the grain still until hovered: a hundred-odd\n    // cards shouldn't each run a frame loop just to twinkle.\n    frozen: false,\n    // The last value of the `revealed` prop acted on.\n    revealedTo: false,\n    kick: () => {},\n  });\n  const focusAfter = useRef<\"chip\" | \"text\" | null>(null);\n\n  const preview = usePreviewPlay();\n  useEffect(() => {\n    engine.current.frozen = preview === false;\n    engine.current.kick();\n  }, [preview]);\n\n  useEffect(() => {\n    const canvas = canvasRef.current;\n    const text = textRef.current;\n    const probe = probeRef.current;\n    if (!canvas || !text || !probe) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n    const e = engine.current;\n    let frame = 0;\n    let onScreen = true;\n    let color = \"\";\n    let colorAge = 0;\n    const still = !!reduceMotion;\n    const block = text.closest<HTMLElement>(\"p, div, li\") ?? text.parentElement;\n\n    // Lines are measured from the text's real fragments and placed relative\n    // to a zero-size probe, because an inline element that wraps has no\n    // single box to anchor an overlay to.\n    const measure = () => {\n      const s = block ? screenScale(block) : 1;\n      e.scale = s;\n      const origin = probe.getBoundingClientRect();\n      const fragments = Array.from(text.getClientRects())\n        .filter((r) => r.width > 0)\n        .map((r) => ({ left: r.left / s, top: r.top / s, right: r.right / s, bottom: r.bottom / s }));\n      if (!fragments.length) return;\n      const ox = origin.left / s;\n      const oy = origin.top / s;\n      const left = Math.min(...fragments.map((r) => r.left));\n      const top = Math.min(...fragments.map((r) => r.top));\n      const right = Math.max(...fragments.map((r) => r.right));\n      const bottom = Math.max(...fragments.map((r) => r.bottom));\n      e.width = Math.ceil(right - left);\n      e.height = Math.ceil(bottom - top);\n      e.rects = fragments.map((r) => ({\n        x: r.left - left,\n        y: r.top - top,\n        w: r.right - r.left,\n        h: r.bottom - r.top,\n      }));\n\n      const dpr = Math.min(2, window.devicePixelRatio || 1);\n      canvas.width = Math.round(e.width * dpr);\n      canvas.height = Math.round(e.height * dpr);\n      canvas.style.width = `${e.width}px`;\n      canvas.style.height = `${e.height}px`;\n      canvas.style.left = `${Math.round(left - ox)}px`;\n      canvas.style.top = `${Math.round(top - oy)}px`;\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n\n      const grains: Grain[] = [];\n      const area = e.rects.reduce((sum, r) => sum + r.w * r.h, 0);\n      const perPx = Math.min(DENSITY, MAX_GRAINS / Math.max(area, 1));\n      for (const r of e.rects) {\n        const count = Math.round(r.w * r.h * perPx);\n        for (let i = 0; i < count; i++) {\n          grains.push({\n            // Inset from the fragment's edge so the drift never leaves it.\n            x: r.x + 1.5 + Math.random() * (r.w - 3),\n            y: r.y + 2 + Math.random() * (r.h - 4),\n            a: 0.35 + Math.random() * 0.65,\n            phase: Math.random() * Math.PI * 2,\n            speed: 0.6 + Math.random() * 1.8,\n            size: Math.random() < 0.8 ? 1 : 1.5,\n          });\n        }\n      }\n      e.grains = grains;\n\n      const last = fragments[fragments.length - 1];\n      setChipPos({\n        left: Math.round(last.right - ox),\n        top: Math.round(last.top - oy),\n      });\n      draw(performance.now());\n    };\n\n    const draw = (now: number) => {\n      // The ink colour comes from the theme; re-read it now and then so a\n      // theme switch is picked up without a style read every frame.\n      if (!color || colorAge++ > 30) {\n        color = getComputedStyle(canvas).color;\n        colorAge = 0;\n      }\n      ctx.clearRect(0, 0, e.width, e.height);\n      ctx.fillStyle = color;\n      const t = still ? 0 : e.time / 1000;\n      const elapsed = now - e.phaseStart;\n      let cover = 1;\n      if (e.phase === \"revealed\") return;\n      if (e.phase === \"covering\") cover = Math.min(1, elapsed / COVER_MS);\n\n      for (const g of e.grains) {\n        // Each grain drifts in a tiny loop and twinkles on its own clock.\n        let x = g.x + Math.sin(t * g.speed + g.phase) * 1.2;\n        let y = g.y + Math.cos(t * g.speed * 0.8 + g.phase) * 0.9;\n        let alpha = g.a * (0.55 + 0.45 * Math.sin(t * g.speed * 2.2 + g.phase)) * cover;\n        if (e.phase === \"dissolving\") {\n          const dx = g.x - e.origin.x;\n          const dy = g.y - e.origin.y;\n          const d = Math.hypot(dx, dy) || 1;\n          const local = elapsed - (d / e.maxDistance) * SPREAD_MS;\n          if (local > 0) {\n            const p = Math.min(1, local / GRAIN_FADE_MS);\n            // Blown outward from the click, fast at first, then drifting.\n            const push = (1 - (1 - p) * (1 - p)) * 7;\n            x += (dx / d) * push;\n            y += (dy / d) * push * 0.6;\n            alpha *= 1 - p;\n          }\n        }\n        if (alpha <= 0.02) continue;\n        ctx.globalAlpha = alpha;\n        ctx.fillRect(x, y, g.size, g.size);\n      }\n      ctx.globalAlpha = 1;\n    };\n\n    const loop = (now: number) => {\n      frame = 0;\n      e.time += e.last ? Math.min(now - e.last, 50) : 0;\n      e.last = now;\n      const elapsed = now - e.phaseStart;\n      if (e.phase === \"dissolving\" && elapsed > SPREAD_MS + GRAIN_FADE_MS) {\n        e.phase = \"revealed\";\n        ctx.clearRect(0, 0, e.width, e.height);\n        return;\n      }\n      if (e.phase === \"covering\" && elapsed > COVER_MS) e.phase = \"hidden\";\n      draw(now);\n      // Sleeps when revealed, offscreen, in a hidden tab or with reduced motion.\n      const idle = e.phase === \"hidden\" && e.frozen;\n      if (e.phase !== \"revealed\" && !idle && onScreen && !document.hidden && !still) {\n        frame = requestAnimationFrame(loop);\n      } else {\n        e.last = 0;\n      }\n    };\n\n    e.kick = () => {\n      if (still) {\n        if (e.phase === \"dissolving\") e.phase = \"revealed\";\n        if (e.phase === \"covering\") e.phase = \"hidden\";\n        if (e.phase === \"revealed\") ctx.clearRect(0, 0, e.width, e.height);\n        else draw(0);\n        return;\n      }\n      if (!frame && !(e.phase === \"hidden\" && e.frozen)) frame = requestAnimationFrame(loop);\n    };\n\n    const io = new IntersectionObserver(([entry]) => {\n      onScreen = entry.isIntersecting;\n      if (onScreen) e.kick();\n    });\n    io.observe(canvas);\n    const onVisibility = () => {\n      if (!document.hidden) e.kick();\n    };\n    document.addEventListener(\"visibilitychange\", onVisibility);\n\n    // Re-measure whenever the paragraph reflows, and once fonts settle.\n    const ro = new ResizeObserver(() => measure());\n    if (block) ro.observe(block);\n    let alive = true;\n    document.fonts?.ready.then(() => {\n      if (alive) measure();\n    });\n    measure();\n    e.kick();\n\n    return () => {\n      alive = false;\n      cancelAnimationFrame(frame);\n      e.last = 0;\n      io.disconnect();\n      ro.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n      e.kick = () => {};\n    };\n  }, [reduceMotion]);\n\n  useEffect(() => () => clearTimeout(settleTimer.current), []);\n\n  useEffect(() => {\n    const target = focusAfter.current;\n    if (target === \"chip\" && settled) chipRef.current?.focus();\n    else if (target === \"text\" && !revealed) textRef.current?.focus();\n    else return;\n    focusAfter.current = null;\n  }, [revealed, settled]);\n\n  // `along` is where on the first line the dissolve starts, 0 to 1.\n  const reveal = (clientX?: number, clientY?: number, along = 0.5) => {\n    const e = engine.current;\n    const canvas = canvasRef.current;\n    if (!canvas) return;\n    const box = canvas.getBoundingClientRect();\n    const first = e.rects[0];\n    // Keyboard reveals start from the middle of the first line.\n    e.origin =\n      clientX === undefined || clientY === undefined\n        ? { x: first ? first.x + first.w * along : 0, y: first ? first.y + first.h / 2 : 0 }\n        : { x: (clientX - box.left) / e.scale, y: (clientY - box.top) / e.scale };\n    e.maxDistance = Math.max(\n      1,\n      ...e.grains.map((g) => Math.hypot(g.x - e.origin.x, g.y - e.origin.y)),\n    );\n    e.phase = \"dissolving\";\n    e.phaseStart = performance.now();\n    e.kick();\n    clearTimeout(settleTimer.current);\n    settleTimer.current = setTimeout(\n      () => setSettled(true),\n      reduceMotion ? 0 : SPREAD_MS + GRAIN_FADE_MS,\n    );\n  };\n\n  const cover = () => {\n    const e = engine.current;\n    e.phase = \"covering\";\n    e.phaseStart = performance.now();\n    e.kick();\n    clearTimeout(settleTimer.current);\n  };\n\n  const hide = () => {\n    cover();\n    setChipOpen(false);\n    setSettled(false);\n    focusAfter.current = \"text\";\n    engine.current.revealedTo = false;\n    setRevealed(false);\n  };\n\n  // Outside control: the words follow the prop during render, the grain\n  // follows it in an effect. It never moves focus, since it isn't the\n  // reader acting.\n  const [lastProp, setLastProp] = useState(revealedProp);\n  if (revealedProp !== lastProp) {\n    setLastProp(revealedProp);\n    if (revealedProp !== undefined && revealedProp !== revealed) {\n      setRevealed(revealedProp);\n      setSettled(false);\n      setChipOpen(false);\n    }\n  }\n  useEffect(() => {\n    const e = engine.current;\n    if (revealedProp === undefined || revealedProp === e.revealedTo) return;\n    e.revealedTo = revealedProp;\n    // Off-centre, the way a finger lands on a word rather than its middle.\n    if (revealedProp) reveal(undefined, undefined, 0.3);\n    else cover();\n    // eslint-disable-next-line react-hooks/exhaustive-deps -- reacts to the prop only\n  }, [revealedProp]);\n\n  const onClick = (event: MouseEvent) => {\n    // Once revealed, a stray click only offers the hide chip; it takes a\n    // second, deliberate press to cover the text again.\n    if (revealed) {\n      setChipOpen((o) => !o);\n      return;\n    }\n    // detail 0 means the click came from the keyboard.\n    setRevealed(true);\n    engine.current.revealedTo = true;\n    if (event.detail === 0) reveal();\n    else reveal(event.clientX, event.clientY);\n  };\n\n  const onKeyDown = (event: KeyboardEvent) => {\n    if (revealed) return;\n    if (event.key === \"Enter\" || event.key === \" \") {\n      event.preventDefault();\n      focusAfter.current = \"chip\";\n      setRevealed(true);\n      engine.current.revealedTo = true;\n      reveal();\n    }\n  };\n\n  return (\n    <span className={cn(\"group/spoiler relative\", className)}>\n      <span ref={probeRef} aria-hidden className=\"pointer-events-none absolute top-0 left-0 size-0\" />\n      <span\n        ref={textRef}\n        role={revealed ? undefined : \"button\"}\n        tabIndex={revealed ? -1 : 0}\n        aria-label={revealed ? undefined : \"Spoiler, press to reveal\"}\n        onClick={onClick}\n        onKeyDown={onKeyDown}\n        className={cn(\n          // The padding is cancelled by an equal negative margin, so the pill\n          // around hidden text never shifts the words once it is gone.\n          \"-mx-0.5 rounded-[4px] box-decoration-clone px-0.5 outline-hidden\",\n          \"focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-solid focus-visible:outline-foreground\",\n          \"transition-[color,background-color,filter] ease-[cubic-bezier(0.23,1,0.32,1)]\",\n          revealed\n            ? \"bg-transparent text-inherit filter-none delay-75 duration-300\"\n            : \"cursor-pointer bg-foreground/[0.06] text-transparent blur-[4px] duration-150 select-none motion-reduce:filter-none\",\n        )}\n      >\n        <span aria-hidden={!revealed}>{children}</span>\n      </span>\n      <canvas\n        ref={canvasRef}\n        aria-hidden\n        className={cn(\n          \"pointer-events-none absolute top-0 left-0 text-foreground\",\n          // Before measuring it has no size; keep it from flashing at 300x150.\n          !chipPos && \"invisible\",\n        )}\n      />\n      {revealed && settled && chipPos && (\n        <button\n          ref={chipRef}\n          type=\"button\"\n          aria-label=\"Hide spoiler\"\n          data-open={chipOpen || undefined}\n          onClick={() => hide()}\n          style={{ left: chipPos.left, top: chipPos.top }}\n          className={cn(\n            // Sits just above the end of the spoiler, clear of the words.\n            \"absolute z-10 grid size-6 -translate-x-1/2 -translate-y-[calc(100%+2px)] origin-bottom place-items-center rounded-full bg-background text-muted-foreground shadow-raised outline-hidden\",\n            \"transition-[opacity,scale,color] ease-[cubic-bezier(0.23,1,0.32,1)] hover:text-foreground active:scale-[0.96]\",\n            \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-foreground\",\n            // Hidden until the spoiler is hovered or the chip is focused or\n            // tapped open. The exit waits a beat so the pointer can travel\n            // from the words up to the chip.\n            \"pointer-events-none scale-[0.9] opacity-0 delay-150 duration-150 starting:scale-[0.9] starting:opacity-0\",\n            \"group-hover/spoiler:pointer-events-auto group-hover/spoiler:scale-100 group-hover/spoiler:opacity-100 group-hover/spoiler:delay-0\",\n            \"focus-visible:pointer-events-auto focus-visible:scale-100 focus-visible:opacity-100 focus-visible:delay-0\",\n            \"data-open:pointer-events-auto data-open:scale-100 data-open:opacity-100 data-open:delay-0\",\n          )}\n        >\n          <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden className=\"size-3.5\">\n            <path\n              d=\"M2 8s2.2-4.25 6-4.25S14 8 14 8s-2.2 4.25-6 4.25S2 8 2 8Z M8 9.75a1.75 1.75 0 1 0 0-3.5 1.75 1.75 0 0 0 0 3.5Z M3 13 13 3\"\n              stroke=\"currentColor\"\n              strokeWidth={1.5}\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n            />\n          </svg>\n        </button>\n      )}\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {revealed ? `Revealed: ${children}` : \"\"}\n      </span>\n    </span>\n  );\n}\n\nexport default function SpoilerTextDemo() {\n  const play = usePreviewPlay();\n  const [shown, setShown] = useState([false, false]);\n\n  // Index preview: a reader taps the first spoiler, then the second, reads\n  // for a moment, and the grain drifts back over both.\n  useEffect(() => {\n    if (!play) return;\n    const timers: ReturnType<typeof setTimeout>[] = [];\n    const at = (ms: number, next: boolean[]) => timers.push(setTimeout(() => setShown(next), ms));\n    const show = () => {\n      at(350, [true, false]);\n      at(1150, [true, true]);\n      at(3300, [false, false]);\n      timers.push(setTimeout(show, 4600));\n    };\n    show();\n    return () => {\n      timers.forEach(clearTimeout);\n      setShown([false, false]);\n    };\n  }, [play]);\n\n  // On its own page (play is null) the spoilers are the reader's alone.\n  const revealed = (i: number) => (play === null ? undefined : play && shown[i]);\n\n  return (\n    <p className=\"max-w-[420px] text-base leading-7 text-pretty text-foreground\">\n      Halfway through the book you learn the narrator is{\" \"}\n      <Spoiler revealed={revealed(0)}>the missing sister</Spoiler>. The last chapter shows the\n      lighthouse keeper <Spoiler revealed={revealed(1)}>was never real</Spoiler>, which quietly\n      recolors everything before it.\n    </p>\n  );\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"},{"path":"lib/preview-play.ts","type":"registry:lib","target":"lib/preview-play.ts","content":"\"use client\";\n\nimport { createContext, useContext } from \"react\";\n\n// Lets a demo know whether it's showing in an index card, and whether that\n// card is hovered or focused. Demos use it to act themselves out on hover:\n// a star button stars itself, a checkbox scribbles, and so on.\n//\n// null: not in a preview (the component's own page, or real use).\n// false: in a preview, at rest.\n// true: in a preview, being hovered or focused: play the show.\nexport const PreviewPlayContext = createContext<boolean | null>(null);\n\nexport function usePreviewPlay() {\n  return useContext(PreviewPlayContext);\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["text"],"docs":"From ui lab: https://lab.xevrion.dev/lab/spoiler-text"}