{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chapter-nav",
  "title": "Chapter Nav",
  "description": "Chapter navigation for a long scroll story: a glass rail of dots at the inline end, the current chapter's dot stretched and filling as you read, that opens a panel listing the chapters with a progress bar under each. Controlled or on its own. No dependencies.",
  "files": [
    {
      "path": "registry/mewo/ui/chapter-nav.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport interface ChapterNavChapter {\n  title: string\n  /** Where the chapter starts, in the story's viewport heights. */\n  start: number\n}\n\nexport interface ChapterNavLabels {\n  /** The dots button and the chapters list. */\n  chapters?: string\n  close?: string\n}\n\nexport interface ChapterNavProps extends React.ComponentProps<\"div\"> {\n  /** The chapters, in order. */\n  chapters?: ChapterNavChapter[]\n  /** Chapter boundaries, one more than the chapters: dot i fills from bounds[i] to bounds[i + 1]. Defaults to the\n   * chapters' starts. */\n  bounds?: number[]\n  /** The reader's position, in the same unit as the starts (viewport heights). */\n  progress?: number\n  /** The panel's heading. */\n  title?: string\n  /** The wordmark at the foot of the panel. */\n  brand?: string\n  /** Small line under the wordmark. */\n  copyright?: string\n  /** The dots fade in once true (e.g. when an intro ends). */\n  visible?: boolean\n  /** Whether the chapters panel is open, to control it with onOpenChange (one panel at a time on a page). Left out,\n   * the nav keeps its own state. */\n  open?: boolean\n  onOpenChange?: (open: boolean) => void\n  /** Called with a chapter's start when the reader picks it; the page scrolls there. */\n  onChapter?: (start: number) => void\n  /** Accessible names, in the page's language. */\n  labels?: ChapterNavLabels\n}\n\nconst LABELS: Required<ChapterNavLabels> = { chapters: \"Chapters\", close: \"Close\" }\n\n// The panel's opening runs on a registered number, 0 closed to 1 open, and its clip is worked out from it on every\n// frame. A transition of clip-path itself can be handed to the compositor, which inside a rounded, overflow-clipped\n// box (a demo's frame) draws nothing until it ends, so the panel just appears.\nif (typeof CSS !== \"undefined\" && \"registerProperty\" in CSS) {\n  try {\n    CSS.registerProperty({ name: \"--chapter-nav-reveal\", syntax: \"<number>\", inherits: false, initialValue: \"0\" })\n  } catch {}\n}\nconst GLASS = \"bg-[rgba(186,186,186,0.2)] backdrop-blur-[12px]\"\nconst ITEM_DELAYS = [\"\", \"\", \"delay-50\", \"delay-100\", \"delay-150\", \"delay-200\"]\n\n// panel content rises into place in turn while the panel opens\nconst item = (open: boolean, i: number) =>\n  cn(\n    \"[transition:opacity_0.5s_ease,translate_0.6s_cubic-bezier(0.215,0.61,0.355,1)] motion-reduce:transition-none\",\n    open ? cn(\"translate-y-0 opacity-100\", ITEM_DELAYS[i]) : \"translate-y-[calc(var(--story-u)*4)] opacity-0\"\n  )\n\n/**\n * Chapter navigation for a long scroll story: a glass rail of dots at the inline end, the current chapter's dot\n * stretched and filling as you read, that opens a panel listing the chapters with a progress bar under each.\n * Picking a chapter reports its start; the page scrolls. Mirrors in right-to-left pages.\n */\nexport function ChapterNav({\n  chapters = [\n    { title: \"One object, many keepers\", start: 0 },\n    { title: \"Empires and capitals\", start: 18.6 },\n    { title: \"Scattered and recovered\", start: 51.5 },\n  ],\n  bounds,\n  progress = 0,\n  title = \"Chapters\",\n  brand = \"Atlas\",\n  copyright = \"© Atlas\",\n  visible = true,\n  open,\n  onOpenChange,\n  onChapter,\n  labels,\n  className,\n  ...props\n}: ChapterNavProps) {\n  const [innerOpen, setInnerOpen] = React.useState(false)\n  const isOpen = open ?? innerOpen\n  const l = { ...LABELS, ...labels }\n  const id = React.useId()\n  const button = React.useRef<HTMLButtonElement>(null)\n  const panel = React.useRef<HTMLElement>(null)\n  // focus goes back to the dots only when the nav closed itself, not when another panel took over\n  const returnFocus = React.useRef(false)\n\n  const b = bounds ?? [...chapters.map((c) => c.start), (chapters.at(-1)?.start ?? 0) + 30]\n  const chapter = Math.max(0, chapters.findIndex((_, i) => progress >= b[i]! && progress < b[i + 1]!), progress >= b.at(-1)! ? chapters.length - 1 : 0)\n  const chapterProgress = (i: number) => Math.min(1, Math.max(0, (progress - b[i]!) / (b[i + 1]! - b[i]!)))\n\n  const setOpen = (next: boolean, focusButton = false) => {\n    returnFocus.current = !next && focusButton\n    if (open === undefined) setInnerOpen(next)\n    onOpenChange?.(next)\n  }\n\n  React.useEffect(() => {\n    if (isOpen) panel.current?.querySelector<HTMLElement>(\"[data-chapter]\")?.focus()\n    else if (returnFocus.current) button.current?.focus()\n    returnFocus.current = false\n  }, [isOpen])\n\n  React.useEffect(() => {\n    if (!isOpen) return\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key !== \"Escape\") return\n      returnFocus.current = true\n      if (open === undefined) setInnerOpen(false)\n      onOpenChange?.(false)\n    }\n    window.addEventListener(\"keydown\", onKey)\n    return () => window.removeEventListener(\"keydown\", onKey)\n  }, [isOpen, open, onOpenChange])\n\n  return (\n    <div data-slot=\"chapter-nav\" className={cn(\"[--story-u:min(2.6667cqw,12px)] min-[901px]:[--story-u:0.625cqw] min-[901px]:@max-[900px]:[--story-u:min(2.6667cqw,12px)] text-[length:var(--story-u)]\", \"contents\", className)} {...props}>\n      <button\n        ref={button}\n        aria-controls={id}\n        aria-expanded={isOpen}\n        onClick={() => setOpen(!isOpen, true)}\n        className={cn(\n          \"fixed z-[100] top-1/2 end-[calc(var(--story-u)*2)] grid w-[calc(var(--story-u)*4)] -translate-y-1/2 cursor-pointer justify-items-center gap-[4px] rounded-md py-[calc(var(--story-u)*2)] transition-opacity duration-600 motion-reduce:transition-none\",\n          \"max-[900px]:end-[calc(var(--story-u)*1)] max-[900px]:w-[calc(var(--story-u)*2)] max-[900px]:py-[calc(var(--story-u)*1)]\",\n          GLASS,\n          visible ? \"opacity-100\" : \"pointer-events-none opacity-0\"\n        )}\n      >\n        {chapters.map((c, i) => (\n          <span\n            key={c.title}\n            className={cn(\n              \"relative h-[5px] w-[5px] overflow-hidden rounded-[calc(var(--story-u)*0.25)] transition-[height] duration-800 ease-in-out motion-reduce:transition-none\",\n              \"before:absolute before:inset-0 before:bg-black before:opacity-10 before:content-['']\",\n              i === chapter && \"h-[calc(var(--story-u)*3)]\"\n            )}\n          >\n            <span\n              className=\"absolute inset-0 bg-black\"\n              style={{ transform: i === chapter ? `translateY(${(chapterProgress(i) - 1) * 100}%)` : \"translateY(-100%)\" }}\n            />\n          </span>\n        ))}\n        <span className=\"sr-only\">{l.chapters}</span>\n      </button>\n      <section\n        id={id}\n        ref={panel}\n        aria-label={l.chapters}\n        inert={!isOpen}\n        className={cn(\n          \"fixed z-[100] end-[calc(var(--story-u)*2)] top-1/2 grid w-[calc(var(--story-u)*50.8)] -translate-y-1/2 content-start gap-[calc(var(--story-u)*2)] rounded-lg px-[calc(var(--story-u)*4)] pt-[calc(var(--story-u)*4)] pb-[calc(var(--story-u)*3.6)]\",\n          \"max-[900px]:start-[calc(var(--story-u)*1.5)] max-[900px]:end-[calc(var(--story-u)*1.5)] max-[900px]:top-auto max-[900px]:bottom-[calc(var(--story-u)*1.5)] max-[900px]:w-auto max-[900px]:translate-y-0 motion-reduce:transition-none\",\n          GLASS,\n          // closed (reveal 0) the clip is the rail of dots the panel opened from; open (reveal 1) the whole panel\n          \"[clip-path:inset(calc((50%-calc(var(--story-u)*4.5))*(1-var(--chapter-nav-reveal)))_0_calc((50%-calc(var(--story-u)*4.5))*(1-var(--chapter-nav-reveal)))_calc((100%-calc(var(--story-u)*4))*(1-var(--chapter-nav-reveal)))_round_calc(var(--story-u)*1.2))]\",\n          \"rtl:[clip-path:inset(calc((50%-calc(var(--story-u)*4.5))*(1-var(--chapter-nav-reveal)))_calc((100%-calc(var(--story-u)*4))*(1-var(--chapter-nav-reveal)))_calc((50%-calc(var(--story-u)*4.5))*(1-var(--chapter-nav-reveal)))_0_round_calc(var(--story-u)*1.2))]\",\n          isOpen\n            ? \"visible [--chapter-nav-reveal:1] [transition:--chapter-nav-reveal_0.8s_cubic-bezier(0.455,0.03,0.515,0.955)]\"\n            : cn(\n                \"pointer-events-none invisible [--chapter-nav-reveal:0] [transition:--chapter-nav-reveal_0.8s_cubic-bezier(0.455,0.03,0.515,0.955),visibility_0s_linear_0.8s]\"\n              )\n        )}\n      >\n        <button className=\"absolute top-[calc(var(--story-u)*2.2)] end-[calc(var(--story-u)*2.2)] size-[calc(var(--story-u)*1)] cursor-pointer\" onClick={() => setOpen(false, true)}>\n          <svg viewBox=\"0 0 10 10\" aria-hidden=\"true\">\n            <path d=\"M1 1l8 8M9 1l-8 8\" stroke=\"currentColor\" strokeWidth=\"1.2\" />\n          </svg>\n          <span className=\"sr-only\">{l.close}</span>\n        </button>\n        <h2 className={cn(\"text-[length:max(11px,calc(var(--story-u)*1.4))] font-medium tracking-[-0.01em] max-[900px]:text-[length:calc(var(--story-u)*1.4)]\", item(isOpen, 1))}>{title}</h2>\n        <nav aria-label={l.chapters}>\n          <ol className=\"grid\">\n            {chapters.map((c, i) => (\n              <li key={c.title} className={item(isOpen, Math.min(i + 2, 4))}>\n                <button\n                  data-chapter\n                  onClick={() => {\n                    setOpen(false)\n                    onChapter?.(c.start)\n                  }}\n                  className=\"grid w-full cursor-pointer grid-cols-[calc(var(--story-u)*4)_1fr] gap-[calc(var(--story-u)*0.6)] pt-[calc(var(--story-u)*2)] text-start font-heading text-[length:calc(var(--story-u)*1.5)]\"\n                >\n                  <span className=\"font-sans text-[length:calc(var(--story-u)*1.2)] font-medium tracking-[-0.01em] opacity-50\">{String(i + 1).padStart(2, \"0\")}</span>\n                  <span className=\"pb-[calc(var(--story-u)*2)] text-[length:calc(var(--story-u)*2.6)] leading-[1.1]\">{c.title}</span>\n                  <span className=\"col-span-full h-[2px] overflow-hidden bg-[rgba(13,13,13,0.05)]\">\n                    <span className=\"block h-full origin-left bg-black rtl:origin-right\" style={{ transform: `scaleX(${chapterProgress(i)})` }} />\n                  </span>\n                </button>\n              </li>\n            ))}\n          </ol>\n        </nav>\n        <footer className={cn(\"grid gap-[calc(var(--story-u)*1)] pt-[calc(var(--story-u)*1.6)]\", item(isOpen, 5))}>\n          <span className=\"font-heading text-[length:calc(var(--story-u)*2)]\">{brand}</span>\n          <span className=\"text-[9px] opacity-50\">{copyright}</span>\n        </footer>\n      </section>\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "meta": {
    "added": "2026-10-27"
  },
  "categories": [
    "scroll",
    "navigation"
  ],
  "type": "registry:ui"
}