{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "spoiler",
  "type": "registry:ui",
  "title": "Spoiler",
  "description": "Grain-and-blur cover that dissolves outward from where you press it.",
  "categories": [
    "content"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/spoiler.tsx",
      "content": "\"use client\";\nimport { AnimatePresence, animate, motion, useMotionTemplate, useMotionValue, useReducedMotion, useTransform } from \"motion/react\";\nimport { cloneElement, Fragment, isValidElement, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease } from \"@/lib/motion\";\nimport { Eye, EyeOff } from \"@/lib/icons\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\n/* -------------------------------------------------------------------------------------------------\n * Grain\n * -----------------------------------------------------------------------------------------------*/\n\n// Film grain as a mask, not a picture: the SVG only decides where the speckles are, and the\n// element's own token color fills them, so the grain follows the theme with no color of its own.\nconst GRAIN =\n  \"url(\\\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.15' numOctaves='2' seed='7' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 2.6 0 0 0 -0.75'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23g)'/%3E%3C/svg%3E\\\")\";\n// Drawn at half the tile size, so each speck is about a device pixel on a retina screen.\nconst grain: React.CSSProperties = { maskImage: GRAIN, WebkitMaskImage: GRAIN, maskSize: \"60px\", WebkitMaskSize: \"60px\" };\n\n/* -------------------------------------------------------------------------------------------------\n * Words\n * -----------------------------------------------------------------------------------------------*/\n\n// The grain over one piece of text. Every piece's cover is the line's height, centered, and\n// pieces sit exactly edge to edge, so a phrase reads as one cover across spaces and line breaks.\nfunction Cover() {\n  return (\n    <span\n      aria-hidden\n      style={grain}\n      className={cn(\n        \"pointer-events-none absolute inset-x-0 top-1/2 h-[calc(1lh-4px)] -translate-y-1/2 bg-fg-3\",\n        \"transition-[opacity,scale,filter] duration-[420ms] ease-out-expo [transition-delay:var(--d,0ms)]\",\n        \"group-hover/spoiler:group-data-[state=hidden]/spoiler:bg-fg-2\",\n        \"group-data-[state=revealed]/spoiler:scale-125 group-data-[state=revealed]/spoiler:opacity-0 group-data-[state=revealed]/spoiler:blur-[2px]\",\n        \"motion-reduce:scale-100! motion-reduce:blur-none!\",\n      )}\n    />\n  );\n}\n\n// Each word carries its own cover, so the grain follows the text across line breaks and the\n// reveal can travel outward from the word that was clicked.\nfunction Word({ children }: { children: React.ReactNode }) {\n  return (\n    <span data-spoiler-piece className=\"relative inline-block\">\n      <span\n        className={cn(\n          \"inline-block transition-[filter,opacity] duration-300 ease-out-expo [transition-delay:var(--d,0ms)]\",\n          \"group-data-[state=hidden]/spoiler:opacity-30 group-data-[state=hidden]/spoiler:blur-[5px] group-data-[state=hidden]/spoiler:select-none\",\n          \"motion-reduce:blur-none!\",\n        )}\n      >\n        {children}\n      </span>\n      <Cover />\n    </span>\n  );\n}\n\n// The space between two words, covered too. At a line break it collapses to nothing, and so does its cover.\nfunction Gap({ children }: { children: string }) {\n  return (\n    <span data-spoiler-piece className=\"relative\">\n      {children}\n      <Cover />\n    </span>\n  );\n}\n\nfunction splitWords(node: React.ReactNode, key = \"w\"): React.ReactNode {\n  if (typeof node === \"string\" || typeof node === \"number\") {\n    return String(node)\n      .split(/(\\s+)/)\n      .map((part, i) =>\n        part === \"\" ? null : /^\\s+$/.test(part) ? <Gap key={`${key}.${i}`}>{part}</Gap> : <Word key={`${key}.${i}`}>{part}</Word>,\n      );\n  }\n  if (Array.isArray(node)) return node.map((n, i) => <Fragment key={i}>{splitWords(n, `${key}.${i}`)}</Fragment>);\n  if (isValidElement<{ children?: React.ReactNode }>(node)) {\n    // Formatting (em, strong, code) keeps its element and splits inside; anything else is one piece.\n    if (node.props.children != null) return cloneElement(node, undefined, splitWords(node.props.children, key));\n    return <Word key={key}>{node}</Word>;\n  }\n  return node;\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Shared state\n * -----------------------------------------------------------------------------------------------*/\n\ntype RevealProps = {\n  /** Controlled: whether the content is showing. */\n  revealed?: boolean;\n  /** Uncontrolled starting state. */\n  defaultRevealed?: boolean;\n  onRevealedChange?: (revealed: boolean) => void;\n  /** Whether it can be covered again once revealed. */\n  rehide?: boolean;\n};\n\n/* -------------------------------------------------------------------------------------------------\n * Inline\n * -----------------------------------------------------------------------------------------------*/\n\nexport type SpoilerProps = Omit<React.ComponentProps<\"span\">, \"children\"> &\n  RevealProps & {\n    /** Text, with formatting if you like. Keep links and buttons out: the spoiler is itself the control. */\n    children: React.ReactNode;\n    /** What a screen reader hears while it's covered. */\n    label?: string;\n  };\n\n/** A phrase inside running text, covered in grain until it's pressed. */\nexport function Spoiler({\n  revealed: revealedProp,\n  defaultRevealed = false,\n  onRevealedChange,\n  rehide = true,\n  label = \"Spoiler\",\n  children,\n  className,\n  onClick,\n  onKeyDown,\n  ...rest\n}: SpoilerProps) {\n  const [revealed, setRevealed] = useControllableState({ value: revealedProp, defaultValue: defaultRevealed, onChange: onRevealedChange });\n  const ref = useRef<HTMLSpanElement>(null);\n  const reduce = useReducedMotion();\n  const interactive = !revealed || rehide;\n\n  const toggle = (from?: { x: number; y: number }) => {\n    const root = ref.current;\n    if (!root) return;\n    const words = Array.from(root.querySelectorAll<HTMLElement>(\"[data-spoiler-piece]\"));\n    if (!revealed && !reduce) {\n      // The reveal travels outward from where it was pressed (the first word, from the keyboard),\n      // ~1.1px per ms, capped so a long passage never makes anyone wait.\n      const first = words[0]?.getBoundingClientRect();\n      const o = from ?? (first ? { x: first.left, y: first.top + first.height / 2 } : { x: 0, y: 0 });\n      for (const w of words) {\n        const r = w.getBoundingClientRect();\n        const d = Math.hypot(r.left + r.width / 2 - o.x, r.top + r.height / 2 - o.y);\n        w.style.setProperty(\"--d\", `${Math.min(320, Math.round(d / 1.1))}ms`);\n      }\n    } else {\n      // Covering again is one quick step, no travel.\n      for (const w of words) w.style.setProperty(\"--d\", \"0ms\");\n    }\n    setRevealed(!revealed);\n  };\n\n  return (\n    <span\n      ref={ref}\n      role={interactive ? \"button\" : undefined}\n      tabIndex={interactive ? 0 : undefined}\n      aria-expanded={interactive ? revealed : undefined}\n      aria-label={revealed ? undefined : label}\n      data-state={revealed ? \"revealed\" : \"hidden\"}\n      onClick={(e) => {\n        onClick?.(e);\n        if (e.defaultPrevented || !interactive) return;\n        // Selecting revealed text to copy it shouldn't cover it again.\n        if (revealed && window.getSelection()?.toString()) return;\n        toggle({ x: e.clientX, y: e.clientY });\n      }}\n      onKeyDown={(e) => {\n        onKeyDown?.(e);\n        if (e.defaultPrevented || !interactive) return;\n        if (e.key === \"Enter\" || e.key === \" \") {\n          e.preventDefault();\n          toggle();\n        }\n      }}\n      className={cn(\n        \"group/spoiler rounded-[4px] px-[2px] [box-decoration-break:clone] [-webkit-box-decoration-break:clone]\",\n        \"transition-[background-color] duration-300 ease-out\",\n        \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n        \"data-[state=hidden]:cursor-pointer data-[state=hidden]:bg-fg/[0.07] data-[state=hidden]:hover:bg-fg/10\",\n        // Once revealed it keeps a faint wash, so it still reads as the thing you can cover again.\n        rehide && \"data-[state=revealed]:cursor-pointer data-[state=revealed]:bg-fg/[0.04]\",\n        className,\n      )}\n      {...rest}\n    >\n      {splitWords(children)}\n    </span>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Block\n * -----------------------------------------------------------------------------------------------*/\n\nexport type SpoilerBlockProps = Omit<React.ComponentProps<\"div\">, \"children\"> &\n  RevealProps & {\n    /** An image, a video, a card: anything that should stay covered until asked for. */\n    children: React.ReactNode;\n    /** The button on the cover. */\n    label?: string;\n    /** One line under the button saying what's hidden, e.g. \"Season 2 finale\". */\n    description?: string;\n    /** The hide button's name, once revealed. */\n    hideLabel?: string;\n  };\n\n/** Media or a whole card under a blur-and-grain cover that dissolves from where it's pressed. */\nexport function SpoilerBlock({\n  revealed: revealedProp,\n  defaultRevealed = false,\n  onRevealedChange,\n  rehide = true,\n  label = \"Show spoiler\",\n  description,\n  hideLabel = \"Hide spoiler\",\n  children,\n  className,\n  ...rest\n}: SpoilerBlockProps) {\n  const [revealed, setRevealed] = useControllableState({ value: revealedProp, defaultValue: defaultRevealed, onChange: onRevealedChange });\n  const [dissolving, setDissolving] = useState(false);\n  const root = useRef<HTMLDivElement>(null);\n  const cover = useRef<HTMLButtonElement>(null);\n  const hide = useRef<HTMLButtonElement>(null);\n  const reduce = useReducedMotion();\n\n  // The cover's mask: a hole that opens from the press point, with a soft, grainy edge.\n  const x = useMotionValue(0);\n  const y = useMotionValue(0);\n  const r = useMotionValue(0);\n  const edge = useTransform(r, (v) => v + 56);\n  const mask = useMotionTemplate`radial-gradient(circle at ${x}px ${y}px, transparent ${r}px, black ${edge}px)`;\n\n  const show = (from: { x: number; y: number } | null) => {\n    const box = root.current?.getBoundingClientRect();\n    const keyboard = from === null;\n    setRevealed(true);\n    // Focus follows to the hide button (or the content), so it isn't dropped with the cover.\n    requestAnimationFrame(() => (hide.current ?? root.current)?.focus({ preventScroll: true }));\n    if (!box || reduce) return;\n    const ox = keyboard ? box.width / 2 : from.x - box.left;\n    const oy = keyboard ? box.height / 2 : from.y - box.top;\n    x.set(ox);\n    y.set(oy);\n    r.set(0);\n    setDissolving(true);\n    // Far enough to clear the farthest corner, plus the soft edge.\n    const far = Math.max(Math.hypot(ox, oy), Math.hypot(box.width - ox, oy), Math.hypot(ox, box.height - oy), Math.hypot(box.width - ox, box.height - oy)) + 56;\n    animate(r, far, { duration: 0.56, ease: ease.outQuart }).then(() => setDissolving(false));\n  };\n\n  const conceal = () => {\n    r.set(0);\n    setDissolving(false);\n    setRevealed(false);\n    requestAnimationFrame(() => cover.current?.focus({ preventScroll: true }));\n  };\n\n  const covered = !revealed || dissolving;\n\n  return (\n    <div\n      ref={root}\n      tabIndex={-1}\n      data-state={revealed ? \"revealed\" : \"hidden\"}\n      className={cn(\"group/spoiler relative isolate overflow-hidden rounded-xl outline-none\", className)}\n      {...rest}\n    >\n      <div\n        inert={!revealed}\n        aria-hidden={!revealed || undefined}\n        className={cn(\n          \"transition-[filter,scale] duration-500 ease-out-expo\",\n          \"group-data-[state=hidden]/spoiler:scale-[1.08] group-data-[state=hidden]/spoiler:blur-xl group-data-[state=hidden]/spoiler:duration-200\",\n          \"motion-reduce:scale-100!\",\n        )}\n      >\n        {children}\n      </div>\n\n      <AnimatePresence initial={false}>\n        {covered && (\n          <motion.div\n            key=\"cover\"\n            className=\"absolute inset-0\"\n            style={dissolving ? { maskImage: mask, WebkitMaskImage: mask } : undefined}\n            initial={{ opacity: 0 }}\n            animate={{ opacity: 1 }}\n            exit={{ opacity: 0, transition: { duration: dissolving ? 0 : 0.15 } }}\n            transition={{ duration: reduce ? 0.12 : 0.2, ease: ease.out }}\n          >\n            <div aria-hidden className=\"absolute inset-0 bg-frame/45\" />\n            <div aria-hidden style={grain} className=\"absolute inset-0 bg-fg/25\" />\n            <button\n              ref={cover}\n              type=\"button\"\n              disabled={revealed}\n              onClick={(e) => show(e.detail === 0 ? null : { x: e.clientX, y: e.clientY })}\n              className=\"group/cover absolute inset-0 flex cursor-pointer items-center justify-center p-4 outline-none disabled:cursor-default\"\n            >\n              {/* One surface for the label and what's behind it, so both read on any image in either theme. */}\n              <motion.span\n                animate={dissolving ? { opacity: 0, scale: 0.96 } : { opacity: 1, scale: 1 }}\n                transition={{ duration: dissolving ? 0.14 : 0.2, ease: ease.out }}\n                className={cn(\n                  \"flex max-w-full flex-col items-center gap-0.5 border border-line-2 bg-raised text-fg shadow-pop\",\n                  description ? \"rounded-xl px-3.5 py-2\" : \"rounded-full px-3 py-1.5\",\n                  \"transition-[background-color,border-color,scale] duration-150 ease-out group-hover/cover:border-fg-4 group-active/cover:scale-[0.97] group-active/cover:duration-75\",\n                  \"group-focus-visible/cover:outline-solid group-focus-visible/cover:outline-1 group-focus-visible/cover:outline-offset-2 group-focus-visible/cover:outline-fg-2\",\n                )}\n              >\n                <span className=\"flex items-center gap-1.5 text-[12.5px] font-medium leading-[18px]\">\n                  <Eye size={14} className=\"shrink-0 text-fg-2\" />\n                  {label}\n                </span>\n                {description && <span className=\"max-w-[28ch] text-center text-[11.5px] leading-4 text-fg-3 text-balance\">{description}</span>}\n              </motion.span>\n            </button>\n          </motion.div>\n        )}\n      </AnimatePresence>\n\n      {revealed && rehide && (\n        // Out of the way on a pointer until you're over the media or tabbing to it; always there on touch.\n        <span className=\"absolute right-2 top-2 transition-opacity duration-150 pointer-fine:opacity-0 pointer-fine:group-hover/spoiler:opacity-100 pointer-fine:has-focus-visible:opacity-100\">\n          <motion.button\n            ref={hide}\n            type=\"button\"\n            aria-label={hideLabel}\n            onClick={conceal}\n            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.9 }}\n            animate={{ opacity: 1, scale: 1 }}\n            transition={{ duration: 0.18, ease: ease.out, delay: dissolving ? 0.3 : 0 }}\n            className={cn(\n              \"relative grid size-7 place-items-center rounded-md border border-line-2 bg-raised/90 text-fg-2 shadow-pop outline-none\",\n              \"before:absolute before:-inset-2 before:content-[''] pointer-fine:before:hidden\",\n              \"transition-[background-color,color,scale] duration-150 hover:bg-raised hover:text-fg active:scale-[0.92] active:duration-75\",\n              \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n            )}\n          >\n            <EyeOff size={14} />\n          </motion.button>\n        </span>\n      )}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/spoiler"
}
