{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "heading-anchor",
  "type": "registry:ui",
  "title": "Heading Anchor",
  "description": "A heading whose # link copies the section URL, ticks and marks the section.",
  "categories": [
    "display"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/heading-anchor.tsx",
      "content": "\"use client\";\nimport { Tooltip } from \"@base-ui/react/tooltip\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\n\n/** \"Rate limits & retries\" → \"rate-limits-retries\". Deterministic, so server and client agree. */\nexport function slugify(text: string) {\n  return text\n    .normalize(\"NFKD\")\n    .replace(/[̀-ͯ]/g, \"\")\n    .toLowerCase()\n    .replace(/[’'\"]/g, \"\")\n    .replace(/[^\\p{L}\\p{N}]+/gu, \"-\")\n    .replace(/^-+|-+$/g, \"\");\n}\n\nconst textOf = (node: React.ReactNode): string => {\n  if (typeof node === \"string\" || typeof node === \"number\") return String(node);\n  if (Array.isArray(node)) return node.map(textOf).join(\"\");\n  if (node && typeof node === \"object\" && \"props\" in node) return textOf((node.props as { children?: React.ReactNode }).children);\n  return \"\";\n};\n\nasync function writeText(text: string) {\n  if (navigator.clipboard?.writeText && window.isSecureContext) return navigator.clipboard.writeText(text);\n  const area = document.createElement(\"textarea\");\n  area.value = text;\n  area.setAttribute(\"readonly\", \"\");\n  area.style.cssText = \"position:fixed;top:0;left:0;opacity:0;pointer-events:none\";\n  document.body.appendChild(area);\n  area.select();\n  const ok = document.execCommand(\"copy\");\n  area.remove();\n  if (!ok) throw new Error(\"The browser refused the copy command\");\n}\n\ntype LinkState = \"idle\" | \"copied\" | \"failed\";\n\n/**\n * The section-link lifecycle on its own: builds the URL for `#id`, copies it, puts the hash in\n * the address bar without a jump, and reports whether the page was opened at this section.\n */\nexport function useSectionLink(id: string, { timeout = 1600, updateHash = true }: { timeout?: number; updateHash?: boolean } = {}) {\n  const [state, setState] = useState<LinkState>(\"idle\");\n  const [targeted, setTargeted] = useState(false);\n  const timer = useRef<number>(undefined);\n  const flash = useRef<number>(undefined);\n\n  const mark = useCallback(() => {\n    setTargeted(true);\n    window.clearTimeout(flash.current);\n    flash.current = window.setTimeout(() => setTargeted(false), 1400);\n  }, []);\n\n  useEffect(() => {\n    // Arriving at #id (on load, or from a link elsewhere) briefly marks the heading so the eye finds it.\n    const check = () => {\n      if (decodeURIComponent(window.location.hash.slice(1)) === id) mark();\n    };\n    const first = window.setTimeout(check, 0);\n    window.addEventListener(\"hashchange\", check);\n    return () => {\n      window.clearTimeout(first);\n      window.removeEventListener(\"hashchange\", check);\n      window.clearTimeout(flash.current);\n      window.clearTimeout(timer.current);\n    };\n  }, [id, mark]);\n\n  const copy = useCallback(async () => {\n    const url = `${window.location.origin}${window.location.pathname}${window.location.search}#${encodeURIComponent(id)}`;\n    // The hash updates even when the clipboard refuses, so the address bar still has the link.\n    if (updateHash) window.history.replaceState(window.history.state, \"\", `#${encodeURIComponent(id)}`);\n    mark();\n    window.clearTimeout(timer.current);\n    let next: LinkState = \"copied\";\n    try {\n      await writeText(url);\n    } catch {\n      next = \"failed\";\n    }\n    setState(next);\n    timer.current = window.setTimeout(() => setState(\"idle\"), timeout);\n    return next === \"copied\" ? url : null;\n  }, [id, timeout, updateHash, mark]);\n\n  return { state, targeted, copy };\n}\n\nconst levels = {\n  h1: \"text-[24px] leading-[1.15] tracking-[-0.025em]\",\n  h2: \"text-[19px] leading-[1.25] tracking-[-0.02em]\",\n  h3: \"text-[15px] leading-[1.35] tracking-[-0.015em]\",\n  h4: \"text-[13.5px] leading-[1.4] tracking-[-0.01em]\",\n  h5: \"text-[13px] leading-[1.45]\",\n  h6: \"text-[12.5px] leading-[1.45] text-fg-2\",\n} as const;\n\nexport type HeadingAnchorProps = Omit<React.ComponentProps<\"h2\">, \"onCopy\"> & {\n  as?: keyof typeof levels;\n  /** The section id. Derived from the heading text when omitted. */\n  id?: string;\n  /** Put #id in the address bar when the link is used. */\n  updateHash?: boolean;\n  /** Called with the URL that reached the clipboard. */\n  onCopied?: (url: string) => void;\n  /** Accessible name for the link. Receives the heading text. */\n  linkLabel?: (text: string) => string;\n  /** Where the link's tooltip portals to. */\n  container?: Tooltip.Portal.Props[\"container\"];\n};\n\nexport function HeadingAnchor({\n  as: Tag = \"h2\",\n  id: idProp,\n  updateHash = true,\n  onCopied,\n  linkLabel = (t) => `Copy link to “${t}”`,\n  container,\n  className,\n  children,\n  ...rest\n}: HeadingAnchorProps) {\n  const text = textOf(children);\n  const id = idProp ?? slugify(text);\n  const { state, targeted, copy } = useSectionLink(id, { updateHash });\n  const reduce = useReducedMotion();\n  const active = state !== \"idle\";\n  const [hoverOpen, setHoverOpen] = useState(false);\n\n  return (\n    <Tag\n      id={id}\n      data-targeted={targeted ? \"\" : undefined}\n      className={cn(\n        // Right padding keeps room for the link at the end of the longest line, so it never hangs outside.\n        \"group/heading relative scroll-mt-(--anchor-offset,16px) pr-8 font-medium text-balance text-fg\",\n        // Landing on the section washes the heading once, then lets go.\n        \"before:pointer-events-none before:absolute before:-inset-x-2 before:-inset-y-1 before:rounded-md before:bg-fg/[0.07] before:opacity-0 before:transition-opacity before:duration-1000 before:ease-out-quart before:content-['']\",\n        \"data-targeted:before:opacity-100 data-targeted:before:duration-150\",\n        levels[Tag],\n        className,\n      )}\n      {...rest}\n    >\n      <span className=\"relative\">{children}</span>\n      {/* No space in the DOM before the link: the gap is margin, so the link never wraps alone onto a new line. */}\n      <span className=\"relative inline-block h-[0.7em] w-0 align-baseline whitespace-nowrap\">\n        <Tooltip.Root open={hoverOpen || active} onOpenChange={(open) => setHoverOpen(open)}>\n          <Tooltip.Trigger\n            delay={400}\n            render={\n              <a\n                href={`#${encodeURIComponent(id)}`}\n                aria-label={linkLabel(text)}\n                data-state={state}\n                onClick={(e) => {\n                  // Modified clicks keep their native meaning (new tab, copy link address).\n                  if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;\n                  e.preventDefault();\n                  copy().then((url) => url && onCopied?.(url));\n                }}\n              />\n            }\n            className={cn(\n              \"absolute top-1/2 left-1 inline-grid size-6 -translate-y-1/2 place-items-center rounded-md text-fg-3\",\n              \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-0 focus-visible:outline-fg-3\",\n              \"-translate-x-0.5 opacity-0 transition-[opacity,translate,background-color,color,scale] duration-150 ease-out\",\n              \"group-hover/heading:translate-x-0 group-hover/heading:opacity-100 focus-visible:translate-x-0 focus-visible:opacity-100\",\n              // Without hover there is nothing to reveal it, so it stays faintly visible.\n              \"pointer-coarse:translate-x-0 pointer-coarse:opacity-60\",\n              \"hover:bg-hover hover:text-fg active:scale-[0.92] active:duration-75 motion-reduce:translate-x-0\",\n              active && \"translate-x-0 opacity-100\",\n              state === \"failed\" && \"text-danger hover:text-danger\",\n              \"before:absolute before:-inset-2.5 before:content-[''] pointer-fine:before:hidden\",\n            )}\n          >\n            <span className=\"relative grid size-4 place-items-center\">\n              <AnimatePresence initial={false}>\n                <motion.span\n                  key={state}\n                  className=\"absolute inset-0 grid place-items-center\"\n                  initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, filter: \"blur(2px)\" }}\n                  animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n                  exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, filter: \"blur(2px)\" }}\n                  transition={reduce ? { duration: 0.12 } : spring.pop}\n                >\n                  <Glyph state={state} reduce={!!reduce} />\n                </motion.span>\n              </AnimatePresence>\n            </span>\n          </Tooltip.Trigger>\n          <Tooltip.Portal container={container}>\n            <Tooltip.Positioner side=\"top\" sideOffset={6} collisionPadding={8} className=\"z-(--z-tooltip)\">\n              <Tooltip.Popup\n                className={cn(\n                  \"rounded-md border border-line-2 bg-raised px-2 py-1 text-[12px] leading-4 font-normal tracking-normal whitespace-nowrap text-fg-2 shadow-pop\",\n                  \"origin-[var(--transform-origin)] transition-[opacity,scale] duration-150 ease-out-expo\",\n                  \"data-starting-style:scale-[0.96] data-starting-style:opacity-0 data-ending-style:opacity-0 data-ending-style:duration-100\",\n                  \"data-instant:transition-none motion-reduce:data-starting-style:scale-100\",\n                  state === \"failed\" && \"text-danger\",\n                )}\n              >\n                {state === \"copied\" ? \"Link copied\" : state === \"failed\" ? \"Couldn’t copy. The link is in the address bar\" : \"Copy link to section\"}\n              </Tooltip.Popup>\n            </Tooltip.Positioner>\n          </Tooltip.Portal>\n        </Tooltip.Root>\n        <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n          {state === \"copied\" ? \"Link copied\" : state === \"failed\" ? \"Couldn’t copy the link\" : \"\"}\n        </span>\n      </span>\n    </Tag>\n  );\n}\n\nfunction Glyph({ state, reduce }: { state: LinkState; reduce: boolean }) {\n  const draw = reduce ? {} : { initial: { pathLength: 0 }, animate: { pathLength: 1 }, transition: { duration: 0.3, ease: ease.out, delay: 0.04 } };\n  const common = {\n    width: 16,\n    height: 16,\n    viewBox: \"0 0 16 16\",\n    fill: \"none\",\n    stroke: \"currentColor\",\n    strokeWidth: 1.4,\n    strokeLinecap: \"round\" as const,\n    strokeLinejoin: \"round\" as const,\n    \"aria-hidden\": true,\n  };\n  if (state === \"copied\")\n    return (\n      <svg {...common}>\n        <motion.path d=\"M3.5 8.5 6.5 11.5 12.5 4.5\" {...draw} />\n      </svg>\n    );\n  if (state === \"failed\")\n    return (\n      <svg {...common}>\n        <motion.path d=\"m4.5 4.5 7 7\" {...draw} />\n        <motion.path d=\"m11.5 4.5-7 7\" {...draw} />\n      </svg>\n    );\n  return (\n    <svg {...common}>\n      <path d=\"M5.75 2.75 4.75 13.25M11.25 2.75l-1 10.5M3 6h10.5M2.5 10H13\" />\n    </svg>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/heading-anchor"
}
