{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "island",
  "type": "registry:component",
  "title": "Island",
  "description": "Morphing sticky header for long reads: progress ring, scrollspy section, section menu, and a Read-next end phase.",
  "dependencies": [],
  "registryDependencies": [
    "https://mocoui.site/r/tokens.json",
    "https://mocoui.site/r/hooks.json"
  ],
  "files": [
    {
      "path": "registry/island/island.tsx",
      "type": "registry:component",
      "target": "components/moco/island.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport \"./island.css\";\nimport {\n  useReadProgress,\n  useScrollspy,\n  useReducedMotion,\n  smoothScrollTo,\n} from \"@/components/moco/hooks\";\n\nexport interface Heading {\n  id: string;\n  text: string;\n  level?: number;\n}\n\nexport interface IslandLink {\n  label: string;\n  href: string;\n}\n\nexport interface IslandProps {\n  title: string;\n  headings: Heading[];\n  /** Minutes. Shown at the top and counted down by the progress ring. */\n  readingTime: number;\n  /** Home / wordmark link shown while reading. */\n  brand: IslandLink;\n  /** Extra links listed under \"Elsewhere\" in the section menu. */\n  links: IslandLink[];\n  /** \"Read next →\" target. The end phase is skipped when absent. */\n  next?: IslandLink | null;\n  /** CSS selector for the article element progress is measured against. */\n  articleSelector?: string;\n}\n\n/**\n * The morphing header island. Compact at scroll 0; expands to reveal the post\n * title, a scrollspy section name and a progress ring as you read; morphs once\n * more into \"Read next →\" at the end.\n */\nexport function Island({\n  title,\n  headings,\n  readingTime,\n  brand,\n  links,\n  next,\n  articleSelector = \"#article-root\",\n}: IslandProps) {\n  const ids = React.useMemo(() => headings.map((h) => h.id), [headings]);\n  const progress = useReadProgress(articleSelector);\n  const active = useScrollspy(ids);\n  const reduced = useReducedMotion();\n\n  const [open, setOpen] = React.useState(false);\n  const [entered, setEntered] = React.useState(false);\n  const [width, setWidth] = React.useState<number | undefined>();\n  const inner = React.useRef<HTMLDivElement>(null);\n  const shell = React.useRef<HTMLDivElement>(null);\n\n  const phase: \"top\" | \"reading\" | \"end\" =\n    progress <= 0.02 ? \"top\" : progress >= 0.94 && next ? \"end\" : \"reading\";\n\n  // Re-measure the content and animate the shell to it.\n  React.useLayoutEffect(() => {\n    const el = inner.current;\n    if (!el) return;\n    const measure = () => setWidth(el.scrollWidth);\n    measure();\n    const ro = new ResizeObserver(measure);\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, [phase, active, title, next]);\n\n  // Slide + fade the swapped text in.\n  React.useEffect(() => {\n    if (reduced) {\n      setEntered(true);\n      return;\n    }\n    setEntered(false);\n    const t = requestAnimationFrame(() => setEntered(true));\n    return () => cancelAnimationFrame(t);\n  }, [phase, reduced]);\n\n  React.useEffect(() => {\n    if (!open) return;\n    const onDoc = (e: MouseEvent) => {\n      if (!shell.current?.contains(e.target as Node)) setOpen(false);\n    };\n    const onKey = (e: KeyboardEvent) => e.key === \"Escape\" && setOpen(false);\n    document.addEventListener(\"mousedown\", onDoc);\n    document.addEventListener(\"keydown\", onKey);\n    return () => {\n      document.removeEventListener(\"mousedown\", onDoc);\n      document.removeEventListener(\"keydown\", onKey);\n    };\n  }, [open]);\n\n  const left = Math.max(1, Math.ceil(readingTime * (1 - progress)));\n  const section = headings[active]?.text;\n\n  return (\n    <div\n      ref={shell}\n      className=\"moco-island\"\n      style={reduced ? undefined : { width }}\n      role=\"navigation\"\n      aria-label=\"Article navigation\"\n    >\n      <div ref={inner} className=\"moco-island-in\">\n        <div className=\"moco-island-slot\" data-enter={entered}>\n          {phase === \"end\" && next ? (\n            <a href={next.href} className=\"moco-island-title\">\n              Read next → {next.label}\n            </a>\n          ) : (\n            <>\n              <a href={brand.href}>\n                {brand.label} <span aria-hidden=\"true\">↑</span>\n              </a>\n\n              {phase === \"top\" ? (\n                <>\n                  <span className=\"moco-island-sep\" aria-hidden=\"true\">\n                    |\n                  </span>\n                  <span className=\"moco-island-section\">{readingTime} min read</span>\n                </>\n              ) : (\n                <>\n                  <span className=\"moco-island-sep\" aria-hidden=\"true\">\n                    |\n                  </span>\n                  <button\n                    type=\"button\"\n                    className=\"moco-island-title\"\n                    aria-expanded={open}\n                    aria-haspopup=\"menu\"\n                    onClick={() => setOpen((v) => !v)}\n                  >\n                    {title} <span aria-hidden=\"true\">⌄</span>\n                  </button>\n                  {section ? (\n                    <>\n                      <span className=\"moco-island-sep moco-hide-sm\" aria-hidden=\"true\">\n                        ›\n                      </span>\n                      <span className=\"moco-island-section moco-hide-sm\">{section}</span>\n                    </>\n                  ) : null}\n                </>\n              )}\n            </>\n          )}\n        </div>\n\n        <Ring progress={progress} label={`${left}m`} />\n      </div>\n\n      {open ? (\n        <div className=\"moco-island-menu\" role=\"menu\" aria-label=\"Sections and site links\">\n          <p className=\"moco-menu-head\">In this essay</p>\n          {headings.map((h, i) => (\n            <button\n              key={h.id}\n              type=\"button\"\n              role=\"menuitem\"\n              aria-current={i === active}\n              onClick={() => {\n                setOpen(false);\n                smoothScrollTo(h.id);\n              }}\n            >\n              {h.text}\n            </button>\n          ))}\n          {links.length ? (\n            <>\n              <hr />\n              <p className=\"moco-menu-head\">Elsewhere</p>\n              {links.map((l) => (\n                <a key={l.href} href={l.href} role=\"menuitem\" onClick={() => setOpen(false)}>\n                  {l.label}\n                </a>\n              ))}\n            </>\n          ) : null}\n        </div>\n      ) : null}\n    </div>\n  );\n}\n\nfunction Ring({ progress, label }: { progress: number; label: string }) {\n  const r = 12;\n  const c = 2 * Math.PI * r;\n  return (\n    <span\n      className=\"moco-ring\"\n      role=\"progressbar\"\n      aria-valuemin={0}\n      aria-valuemax={100}\n      aria-valuenow={Math.round(progress * 100)}\n      aria-valuetext={`${Math.round(progress * 100)} percent read, about ${label} left`}\n    >\n      <svg width=\"30\" height=\"30\" viewBox=\"0 0 30 30\" aria-hidden=\"true\">\n        <circle cx=\"15\" cy=\"15\" r={r} fill=\"none\" stroke=\"var(--moco-line)\" strokeWidth=\"1\" />\n        <circle\n          cx=\"15\"\n          cy=\"15\"\n          r={r}\n          fill=\"none\"\n          stroke=\"var(--moco-accent-dk)\"\n          strokeWidth=\"1.5\"\n          strokeDasharray={c}\n          strokeDashoffset={c * (1 - progress)}\n          style={{ transition: \"stroke-dashoffset 120ms linear\" }}\n        />\n      </svg>\n      <span className=\"moco-ring-time\">{label}</span>\n    </span>\n  );\n}\n"
    },
    {
      "path": "registry/island/island.css",
      "type": "registry:file",
      "target": "components/moco/island.css",
      "content": "/* Element resets the host app may not provide. */\n.moco-island a,\n.moco-island button {\n  font: inherit;\n  color: inherit;\n  background: none;\n  border: 0;\n  padding: 0;\n  cursor: pointer;\n  text-decoration: none;\n  font-variant-numeric: tabular-nums;\n}\n\n.moco-island {\n  position: fixed;\n  top: 16px;\n  left: 50%;\n  transform: translateX(-50%);\n  z-index: 60;\n  display: flex;\n  align-items: center;\n  height: 44px;\n  border: 1px solid var(--moco-line);\n  border-radius: 999px;\n  background: color-mix(in srgb, var(--moco-bg) 78%, transparent);\n  backdrop-filter: blur(14px) saturate(160%);\n  -webkit-backdrop-filter: blur(14px) saturate(160%);\n  font-size: 13px;\n  color: var(--moco-muted);\n  white-space: nowrap;\n  max-width: calc(100vw - 1.5rem);\n  transition: width 400ms var(--moco-ease);\n  overflow: hidden;\n}\n\n.moco-island-in {\n  display: flex;\n  align-items: center;\n  gap: 0.85rem;\n  padding: 0 0.85rem;\n  width: max-content;\n  height: 100%;\n}\n\n.moco-island-slot {\n  display: inline-flex;\n  align-items: center;\n  gap: 0.4rem;\n  transition:\n    transform 320ms var(--moco-ease),\n    opacity 320ms var(--moco-ease);\n}\n\n.moco-island-slot[data-enter=\"false\"] {\n  transform: translateX(32px);\n  opacity: 0;\n}\n\n.moco-island a:hover,\n.moco-island button:hover {\n  color: var(--moco-ink);\n}\n\n.moco-island-sep {\n  color: var(--moco-line);\n}\n\n.moco-island-section {\n  color: var(--moco-faint);\n  max-width: 22ch;\n  overflow: hidden;\n  text-overflow: ellipsis;\n}\n\n.moco-island-title {\n  color: var(--moco-ink);\n  max-width: 26ch;\n  overflow: hidden;\n  text-overflow: ellipsis;\n}\n\n.moco-ring {\n  position: relative;\n  width: 30px;\n  height: 30px;\n  flex: none;\n  display: grid;\n  place-items: center;\n}\n\n.moco-ring svg {\n  position: absolute;\n  inset: 0;\n  transform: rotate(-90deg);\n}\n\n.moco-ring-time {\n  font-size: 10.5px;\n  color: var(--moco-faint);\n  letter-spacing: -0.02em;\n}\n\n.moco-island-menu {\n  position: absolute;\n  top: calc(100% + 8px);\n  left: 50%;\n  transform: translateX(-50%);\n  width: min(360px, calc(100vw - 1.5rem));\n  background: var(--moco-bg);\n  border: 1px solid var(--moco-line);\n  border-radius: 4px;\n  padding: 0.4rem;\n  z-index: 61;\n  box-shadow: none;\n}\n\n.moco-island-menu button,\n.moco-island-menu a {\n  display: block;\n  width: 100%;\n  text-align: left;\n  padding: 0.4rem 0.6rem;\n  font-size: 13px;\n  color: var(--moco-muted);\n  white-space: normal;\n  border-radius: 2px;\n}\n\n.moco-island-menu button:hover,\n.moco-island-menu a:hover,\n.moco-island-menu button[aria-current=\"true\"] {\n  color: var(--moco-ink);\n  background: var(--moco-hover-wash);\n}\n\n.moco-island-menu .moco-menu-head {\n  font-size: 10.5px;\n  letter-spacing: 0.1em;\n  text-transform: uppercase;\n  color: var(--moco-faint);\n  padding: 0.6rem 0.6rem 0.25rem;\n  margin: 0;\n}\n\n.moco-island-menu hr {\n  margin: 0.35rem 0.6rem;\n  border: 0;\n  border-top: 1px solid var(--moco-line);\n}\n\n@media (max-width: 720px) {\n  .moco-island {\n    gap: 0.6rem;\n    font-size: 12px;\n  }\n  .moco-island .moco-hide-sm {\n    display: none;\n  }\n  .moco-island-title {\n    max-width: 16ch;\n  }\n}\n"
    }
  ],
  "docs": "\"use client\";\n\nimport { Island, type Heading } from \"./island\";\n\nconst HEADINGS: Heading[] = [\n  { id: \"why\", text: \"Why islands\" },\n  { id: \"how\", text: \"How it morphs\" },\n  { id: \"end\", text: \"Winding down\" },\n];\n\nexport default function IslandDemo() {\n  return (\n    <div>\n      <Island\n        title=\"A demo essay about islands\"\n        headings={HEADINGS}\n        readingTime={4}\n        brand={{ label: \"Home\", href: \"#\" }}\n        links={[{ label: \"All writing\", href: \"#\" }]}\n        next={{ label: \"The follow-up essay\", href: \"#\" }}\n        articleSelector=\"#article-root\"\n      />\n      <article id=\"article-root\" style={{ maxWidth: \"42rem\", margin: \"0 auto\", padding: \"6rem 1rem\" }}>\n        {HEADINGS.map((h) => (\n          <section key={h.id}>\n            <h2 id={h.id}>{h.text}</h2>\n            {Array.from({ length: 14 }, (_, i) => (\n              <p key={i}>\n                Scroll to watch the island expand, track this section, and finally morph into a\n                Read-next link. This paragraph only exists to give it something to measure.\n              </p>\n            ))}\n          </section>\n        ))}\n      </article>\n    </div>\n  );\n}\n",
  "meta": {
    "tags": [
      "navigation",
      "header",
      "scrollspy",
      "progress"
    ]
  }
}