{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "section-index",
  "title": "Section Index",
  "description": "A numbered map of the chapters in a long read that fills as you go, as a rail, a sticky strip or a one-line counter.",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://miseui.junsu4621.workers.dev/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/miseui/ui/section-index.tsx",
      "content": "import type * as React from \"react\"\nimport { useReducedMotion } from \"motion/react\"\nimport { useSectionIndex } from \"@/registry/miseui/ui/section-index-state\"\nimport { CounterView, RailView, StripView } from \"@/registry/miseui/ui/section-index-views\"\nimport type { SectionIndexItem } from \"@/registry/miseui/ui/section-index-math\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\ntype SectionIndexVariant = \"rail\" | \"strip\" | \"counter\"\ntype SectionIndexEffect = \"progress\"\n\ntype SectionIndexProps = React.ComponentProps<\"nav\"> & {\n  /** The chapters, in page order. Each `id` belongs to a whole section, not to its heading. */\n  items: readonly SectionIndexItem[]\n  /** `rail` numbered rows that fill as you read, `strip` one scrolling line for a sticky bar, `counter` where you are out of how many. */\n  variant?: SectionIndexVariant\n  /** `progress` fills the current section by how far it has been read, not all at once. */\n  effect?: SectionIndexEffect\n  /** The element that scrolls, when it is not the page. */\n  scrollRef?: React.RefObject<HTMLElement | null>\n  /** Space kept above a section after a jump, such as a sticky header's height, in px. */\n  scrollOffset?: number\n  /** Name of the landmark. */\n  label?: string\n  previousLabel?: string\n  nextLabel?: string\n  /** Called after a jump, for example to write the hash into the address. */\n  onNavigate?: (id: string) => void\n}\n\nfunction SectionIndex({\n  items,\n  variant = \"rail\",\n  effect,\n  scrollRef,\n  scrollOffset = 16,\n  label = \"Sections\",\n  previousLabel = \"Previous section\",\n  nextLabel = \"Next section\",\n  onNavigate,\n  className,\n  ...props\n}: SectionIndexProps) {\n  const reduceMotion = useReducedMotion() ?? false\n  const { current, progress, goTo } = useSectionIndex(items, { scrollRef, scrollOffset, reduceMotion })\n  if (items.length === 0) return null\n  const onSelect = (index: number) => {\n    goTo(index)\n    const id = items[index]?.id\n    if (id) onNavigate?.(id)\n  }\n  const view = { items, current, progress, live: effect === \"progress\", onSelect }\n  return (\n    <nav aria-label={label} data-slot=\"section-index\" data-variant={variant} data-effect={effect} className={cn(\"grid min-w-0 grid-cols-[minmax(0,1fr)]\", className)} {...props}>\n      {variant === \"rail\" ? <RailView {...view} /> : null}\n      {variant === \"strip\" ? <StripView {...view} reduceMotion={reduceMotion} /> : null}\n      {variant === \"counter\" ? <CounterView {...view} previousLabel={previousLabel} nextLabel={nextLabel} /> : null}\n    </nav>\n  )\n}\n\nexport { SectionIndex, type SectionIndexProps, type SectionIndexVariant, type SectionIndexEffect, type SectionIndexItem }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/section-index-math.ts",
      "content": "import { clampUnit } from \"@/registry/miseui/lib/motion\"\n\ninterface SectionIndexItem {\n  /** The id of the section element on the page. */\n  id: string\n  title: string\n  /** The label beside the title. Defaults to the position, zero padded. */\n  no?: string\n}\n\ninterface SectionBox {\n  top: number\n  height: number\n}\n\ntype SectionStatus = \"done\" | \"current\" | \"ahead\"\n\n/** The line people are reading at: a little above the middle, where the eye rests in a long page. */\nconst READING_LINE = 0.4\n\nfunction readingLine(viewportTop: number, viewportHeight: number) {\n  return viewportTop + viewportHeight * READING_LINE\n}\n\n/** The last section whose top has passed the line, or -1 above the first. A short last section can never reach the line, so the end of the scroll selects it. */\nfunction currentSection(boxes: readonly SectionBox[], line: number, atEnd: boolean) {\n  if (atEnd) return boxes.length - 1\n  return boxes.reduce((current, box, index) => (box.top <= line ? index : current), -1)\n}\n\nfunction sectionProgress(box: SectionBox | undefined, line: number) {\n  if (!box || box.height <= 0) return 0\n  return clampUnit((line - box.top) / box.height)\n}\n\n/** How far through the whole document, counting finished sections as whole. */\nfunction overallProgress(current: number, withinSection: number, count: number) {\n  if (count <= 0 || current < 0) return 0\n  return clampUnit((current + withinSection) / count)\n}\n\nfunction sectionStatus(index: number, current: number): SectionStatus {\n  if (index === current) return \"current\"\n  return index < current ? \"done\" : \"ahead\"\n}\n\nfunction sectionNumber(item: SectionIndexItem, index: number) {\n  return item.no ?? String(index + 1).padStart(2, \"0\")\n}\n\nfunction atScrollEnd(scrollTop: number, clientHeight: number, scrollHeight: number) {\n  // A page that does not scroll is at no end; the first section is simply being read. Two pixels forgive fractional zoom.\n  return scrollHeight > clientHeight && scrollTop + clientHeight >= scrollHeight - 2\n}\n\nexport { atScrollEnd, currentSection, overallProgress, readingLine, sectionNumber, sectionProgress, sectionStatus, type SectionBox, type SectionIndexItem, type SectionStatus }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/section-index-state.ts",
      "content": "import type * as React from \"react\"\nimport { useCallback, useEffect, useRef, useState } from \"react\"\nimport { useMotionValue } from \"motion/react\"\nimport { atScrollEnd, currentSection, readingLine, sectionProgress, type SectionBox, type SectionIndexItem } from \"@/registry/miseui/ui/section-index-math\"\n\n/** How long a pressed section holds the index if the browser never reports the end of the scroll. */\nconst JUMP_LOCK_MS = 1200\n\ninterface SectionIndexOptions {\n  scrollRef?: React.RefObject<HTMLElement | null>\n  /** Space kept above a section after a jump, such as a sticky header's height, in px. */\n  scrollOffset: number\n  reduceMotion: boolean\n}\n\nfunction readBoxes(ids: readonly string[]): readonly SectionBox[] {\n  return ids.map((id) => {\n    const box = document.getElementById(id)?.getBoundingClientRect()\n    return { top: box?.top ?? Number.POSITIVE_INFINITY, height: box?.height ?? 0 }\n  })\n}\n\nfunction readViewport(root: HTMLElement | null) {\n  if (root) {\n    const metrics = { scrollTop: root.scrollTop, clientHeight: root.clientHeight, scrollHeight: root.scrollHeight }\n    return { top: root.getBoundingClientRect().top, height: root.clientHeight, metrics }\n  }\n  const page = document.documentElement\n  return { top: 0, height: window.innerHeight, metrics: { scrollTop: page.scrollTop, clientHeight: page.clientHeight, scrollHeight: page.scrollHeight } }\n}\n\n/** Which section is being read and how far through it. Unlike a heading tracker it watches whole sections, which is what gives it a position inside one. */\nfunction useSectionIndex(items: readonly SectionIndexItem[], { scrollRef, scrollOffset, reduceMotion }: SectionIndexOptions) {\n  const [current, setCurrent] = useState(-1)\n  const progress = useMotionValue(0)\n  const lockedRef = useRef(false)\n  const timerRef = useRef<number | undefined>(undefined)\n  const idKey = items.map((item) => item.id).join(\" \")\n\n  useEffect(() => {\n    const ids = idKey ? idKey.split(\" \") : []\n    const root = scrollRef?.current ?? null\n    const measure = () => {\n      const boxes = readBoxes(ids)\n      const { top, height, metrics } = readViewport(root)\n      const line = readingLine(top, height)\n      const end = atScrollEnd(metrics.scrollTop, metrics.clientHeight, metrics.scrollHeight)\n      const next = currentSection(boxes, line, end)\n      progress.set(end ? 1 : sectionProgress(boxes[next], line))\n      if (!lockedRef.current) setCurrent(next)\n    }\n    const scroller: EventTarget = root ?? window\n    const release = () => {\n      lockedRef.current = false\n    }\n    scroller.addEventListener(\"scroll\", measure, { passive: true })\n    scroller.addEventListener(\"scrollend\", release)\n    window.addEventListener(\"resize\", measure)\n    queueMicrotask(measure)\n    return () => {\n      scroller.removeEventListener(\"scroll\", measure)\n      scroller.removeEventListener(\"scrollend\", release)\n      window.removeEventListener(\"resize\", measure)\n    }\n  }, [idKey, scrollRef, progress])\n\n  useEffect(() => () => window.clearTimeout(timerRef.current), [])\n\n  const goTo = useCallback(\n    (index: number) => {\n      const target = document.getElementById(items[index]?.id ?? \"\")\n      if (!target) return\n      // While a smooth scroll passes other sections the index would run through them, so it holds on the destination.\n      lockedRef.current = true\n      window.clearTimeout(timerRef.current)\n      timerRef.current = window.setTimeout(() => {\n        lockedRef.current = false\n      }, JUMP_LOCK_MS)\n      setCurrent(index)\n      const root = scrollRef?.current\n      const behavior: ScrollBehavior = reduceMotion ? \"auto\" : \"smooth\"\n      const top = target.getBoundingClientRect().top - scrollOffset\n      if (root) root.scrollTo({ top: root.scrollTop + top - root.getBoundingClientRect().top, behavior })\n      else window.scrollTo({ top: window.scrollY + top, behavior })\n      // Without this, Tab continues from the index instead of from the section someone just jumped to.\n      if (!target.hasAttribute(\"tabindex\")) target.setAttribute(\"tabindex\", \"-1\")\n      target.focus({ preventScroll: true })\n    },\n    [items, scrollRef, scrollOffset, reduceMotion],\n  )\n\n  return { current, progress, goTo }\n}\n\nexport { useSectionIndex }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/section-index-views.tsx",
      "content": "import type * as React from \"react\"\nimport { useEffect, useRef } from \"react\"\nimport { ChevronLeft, ChevronRight } from \"lucide-react\"\nimport { motion, useTransform, type MotionValue } from \"motion/react\"\nimport { overallProgress, sectionNumber, sectionStatus, type SectionIndexItem, type SectionStatus } from \"@/registry/miseui/ui/section-index-math\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\ninterface ViewProps {\n  items: readonly SectionIndexItem[]\n  current: number\n  progress: MotionValue<number>\n  /** Fill the current section by how far it has been read, not all at once. */\n  live: boolean\n  onSelect: (index: number) => void\n}\n\nconst LINK_CLASS =\n  \"relative rounded-sm text-[13px] leading-5 outline-none transition-colors duration-(--duration-fast) ease-(--ease-mise) hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-inset\"\n\nconst STATUS_TEXT: Record<SectionStatus, string> = {\n  current: \"font-medium text-foreground\",\n  done: \"text-foreground/70\",\n  ahead: \"text-muted-foreground\",\n}\n\ninterface FillProps {\n  status: SectionStatus\n  progress: MotionValue<number>\n  live: boolean\n  axis: \"x\" | \"y\"\n  className: string\n}\n\n/** Finished sections stay filled, which is the whole difference from a marker: the index shows how much is behind you. */\nfunction Fill({ status, progress, live, axis, className }: FillProps) {\n  if (status === \"ahead\") return null\n  const moving = live && status === \"current\"\n  const base = cn(\"absolute origin-top-left bg-mise rtl:origin-top-right\", className)\n  if (!moving) return <span aria-hidden=\"true\" data-slot=\"section-index-fill\" className={base} />\n  return <motion.span aria-hidden=\"true\" data-slot=\"section-index-fill\" data-live=\"true\" className={base} style={axis === \"x\" ? { scaleX: progress } : { scaleY: progress }} />\n}\n\nfunction linkProps(item: SectionIndexItem, index: number, current: number, onSelect: (index: number) => void) {\n  return {\n    href: `#${item.id}`,\n    \"aria-current\": index === current ? (\"location\" as const) : undefined,\n    \"data-status\": sectionStatus(index, current),\n    onClick: (event: React.MouseEvent) => {\n      // A modified click means a new tab or window, which the browser should handle.\n      if (event.metaKey || event.ctrlKey || event.shiftKey) return\n      event.preventDefault()\n      onSelect(index)\n    },\n  }\n}\n\nfunction RailView({ items, current, progress, live, onSelect }: ViewProps) {\n  return (\n    <ol data-slot=\"section-index-rail\" className=\"grid min-w-0\">\n      {items.map((item, index) => {\n        const status = sectionStatus(index, current)\n        return (\n          <li key={item.id} className=\"relative min-w-0 before:absolute before:inset-y-0 before:start-0 before:w-px before:bg-border\">\n            <Fill status={status} progress={progress} live={live} axis=\"y\" className=\"inset-y-0 start-0 w-0.5\" />\n            <a {...linkProps(item, index, current, onSelect)} className={cn(LINK_CLASS, \"grid grid-cols-[auto_minmax(0,1fr)] gap-x-2 py-1 ps-3 pe-2 pointer-coarse:py-2.5\", STATUS_TEXT[status])}>\n              <span className=\"font-mono text-xs leading-5 tabular-nums\">{sectionNumber(item, index)}</span>\n              <span className=\"min-w-0 wrap-break-word\">{item.title}</span>\n            </a>\n          </li>\n        )\n      })}\n    </ol>\n  )\n}\n\nfunction StripView({ items, current, progress, live, onSelect, reduceMotion }: ViewProps & { reduceMotion: boolean }) {\n  const listRef = useRef<HTMLOListElement>(null)\n  // Sideways only: scrollIntoView would also move the page, which is exactly what someone reading does not want.\n  useEffect(() => {\n    const list = listRef.current\n    const row = list?.querySelector<HTMLElement>(\"[aria-current]\")\n    if (!list || !row) return\n    list.scrollTo?.({ left: row.offsetLeft - (list.clientWidth - row.offsetWidth) / 2, behavior: reduceMotion ? \"auto\" : \"smooth\" })\n  }, [current, reduceMotion])\n  return (\n    <ol ref={listRef} data-slot=\"section-index-strip\" className=\"relative flex min-w-0 overflow-x-auto shadow-[inset_0_-1px_0_var(--border)] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\">\n      {items.map((item, index) => {\n        const status = sectionStatus(index, current)\n        return (\n          <li key={item.id} className=\"relative shrink-0\">\n            <a {...linkProps(item, index, current, onSelect)} className={cn(LINK_CLASS, \"flex h-10 items-center gap-2 px-3 whitespace-nowrap\", STATUS_TEXT[status])}>\n              <span className=\"font-mono text-xs tabular-nums\">{sectionNumber(item, index)}</span>\n              {item.title}\n              <Fill status={status} progress={progress} live={live} axis=\"x\" className=\"inset-x-0 bottom-0 h-0.5\" />\n            </a>\n          </li>\n        )\n      })}\n    </ol>\n  )\n}\n\nconst STEP_CLASS =\n  \"flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors duration-(--duration-fast) ease-(--ease-mise) hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-40 pointer-coarse:size-10\"\n\ninterface CounterProps extends ViewProps {\n  previousLabel: string\n  nextLabel: string\n}\n\nfunction CounterView({ items, current, progress, live, onSelect, previousLabel, nextLabel }: CounterProps) {\n  const shown = Math.max(current, 0)\n  const item = items[shown]\n  const overall = useTransform(progress, (within) => overallProgress(current, live ? within : 1, items.length))\n  if (!item) return null\n  return (\n    <div data-slot=\"section-index-counter\" className=\"grid min-w-0 grid-cols-[minmax(0,1fr)] gap-2\">\n      <div className=\"flex min-w-0 items-center gap-2\">\n        <p className=\"flex min-w-0 flex-1 items-baseline gap-2 text-[13px] leading-5\">\n          <span className=\"shrink-0 font-mono text-xs text-muted-foreground tabular-nums\">\n            {sectionNumber(item, shown)} / {String(items.length).padStart(2, \"0\")}\n          </span>\n          <a {...linkProps(item, shown, current, onSelect)} className={cn(LINK_CLASS, \"min-w-0 font-medium wrap-break-word text-foreground\")}>\n            {item.title}\n          </a>\n        </p>\n        <button type=\"button\" aria-label={previousLabel} disabled={current <= 0} className={STEP_CLASS} onClick={() => onSelect(current - 1)}>\n          <ChevronLeft aria-hidden=\"true\" className=\"size-4 rtl:rotate-180\" />\n        </button>\n        <button type=\"button\" aria-label={nextLabel} disabled={current >= items.length - 1} className={STEP_CLASS} onClick={() => onSelect(current + 1)}>\n          <ChevronRight aria-hidden=\"true\" className=\"size-4 rtl:rotate-180\" />\n        </button>\n      </div>\n      <span aria-hidden=\"true\" className=\"relative block h-0.5 overflow-hidden rounded-full bg-border\">\n        <motion.span data-slot=\"section-index-overall\" className=\"absolute inset-0 origin-left bg-mise rtl:origin-right\" style={{ scaleX: overall }} />\n      </span>\n    </div>\n  )\n}\n\nexport { CounterView, RailView, StripView }\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "navigation"
  ],
  "type": "registry:ui"
}