{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "floating-index",
  "title": "Floating Index",
  "description": "A compact floating outline that tracks reading progress and the active section.",
  "dependencies": [
    "lucide-react@^1.31.0",
    "motion@^13.1.0"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/default/floating-index/floating-index.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ChevronDown } from \"lucide-react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type FloatingIndexPosition =\n  \"top\" | \"bottom\" | \"top-left\" | \"top-right\" | \"bottom-left\" | \"bottom-right\"\n\n/** Where it sits. Anything else is a matter of className. */\nconst POSITIONS: Record<FloatingIndexPosition, string> = {\n  top: \"top-6 left-1/2 -translate-x-1/2\",\n  bottom: \"bottom-6 left-1/2 -translate-x-1/2\",\n  \"top-left\": \"top-6 left-6\",\n  \"top-right\": \"top-6 right-6\",\n  \"bottom-left\": \"bottom-6 left-6\",\n  \"bottom-right\": \"bottom-6 right-6\",\n}\n\nexport interface FloatingIndexItem {\n  id: string\n  label: string\n  icon?: React.ReactNode\n}\n\nexport interface FloatingIndexProps extends Omit<\n  React.ComponentPropsWithoutRef<\"nav\">,\n  \"onChange\"\n> {\n  items: FloatingIndexItem[]\n  label?: string\n  /**\n   * Once the reader is into the page, the trigger says which section they are\n   * in rather than repeating the label. On by default.\n   */\n  showActiveLabel?: boolean\n  /**\n   * Which corner it floats in. Defaults to the top, centred. Moving it should\n   * not mean undoing the default's utilities one at a time.\n   */\n  position?: FloatingIndexPosition\n  activeId?: string\n  defaultActiveId?: string\n  onActiveChange?: (id: string) => void\n  container?: HTMLElement | null\n  containerRef?: React.RefObject<HTMLElement | null>\n}\n\nexport const FloatingIndex = React.forwardRef<HTMLElement, FloatingIndexProps>(\n  function FloatingIndex(\n    {\n      items,\n      label = \"Index\",\n      showActiveLabel = true,\n      position = \"top\",\n      activeId: controlledActiveId,\n      defaultActiveId,\n      onActiveChange,\n      container,\n      containerRef,\n      className,\n      \"aria-label\": ariaLabel,\n      ...navProps\n    },\n    forwardedRef\n  ) {\n    const [open, setOpen] = React.useState(false)\n    const [progress, setProgress] = React.useState(0)\n    const [uncontrolledActiveId, setUncontrolledActiveId] = React.useState(\n      defaultActiveId ?? items[0]?.id ?? \"\"\n    )\n    const contentId = React.useId()\n    const prefersReducedMotion = useReducedMotion()\n    const activeId = controlledActiveId ?? uncontrolledActiveId\n    const activeIdRef = React.useRef(activeId)\n\n    React.useEffect(() => {\n      activeIdRef.current = activeId\n    }, [activeId])\n\n    const updateActiveId = React.useCallback(\n      (id: string) => {\n        if (activeIdRef.current === id) return\n        activeIdRef.current = id\n        if (controlledActiveId === undefined) setUncontrolledActiveId(id)\n        onActiveChange?.(id)\n      },\n      [controlledActiveId, onActiveChange]\n    )\n\n    React.useEffect(() => {\n      const root = container ?? containerRef?.current ?? null\n      const targets = items\n        .map((item) => document.getElementById(item.id))\n        .filter((target): target is HTMLElement => target !== null)\n\n      if (targets.length === 0) return\n\n      const observer = new IntersectionObserver(\n        (entries) => {\n          const visibleEntry = entries\n            .filter((entry) => entry.isIntersecting)\n            .sort(\n              (first, second) =>\n                first.boundingClientRect.top - second.boundingClientRect.top\n            )[0]\n\n          if (visibleEntry) updateActiveId(visibleEntry.target.id)\n        },\n        {\n          root,\n          rootMargin: \"-20% 0px -70% 0px\",\n          threshold: 0,\n        }\n      )\n\n      targets.forEach((target) => observer.observe(target))\n      return () => observer.disconnect()\n    }, [container, containerRef, items, updateActiveId])\n\n    React.useEffect(() => {\n      const root = container ?? containerRef?.current ?? null\n      const scrollTarget: HTMLElement | Window = root ?? window\n      let frame = 0\n\n      function readProgress() {\n        const scrollTop = root ? root.scrollTop : window.scrollY\n        const scrollHeight = root\n          ? root.scrollHeight - root.clientHeight\n          : document.documentElement.scrollHeight - window.innerHeight\n        const nextProgress =\n          scrollHeight > 0 ? Math.min(scrollTop / scrollHeight, 1) : 0\n\n        setProgress(nextProgress)\n        if (nextProgress > 0.995 && items.at(-1)?.id) {\n          updateActiveId(items.at(-1)!.id)\n        }\n      }\n\n      function scheduleRead() {\n        window.cancelAnimationFrame(frame)\n        frame = window.requestAnimationFrame(readProgress)\n      }\n\n      readProgress()\n      scrollTarget.addEventListener(\"scroll\", scheduleRead, { passive: true })\n      window.addEventListener(\"resize\", scheduleRead)\n\n      const resizeObserver = new ResizeObserver(scheduleRead)\n      resizeObserver.observe(root ?? document.documentElement)\n\n      return () => {\n        window.cancelAnimationFrame(frame)\n        scrollTarget.removeEventListener(\"scroll\", scheduleRead)\n        window.removeEventListener(\"resize\", scheduleRead)\n        resizeObserver.disconnect()\n      }\n    }, [container, containerRef, items, updateActiveId])\n\n    function navigateTo(item: FloatingIndexItem) {\n      const target = document.getElementById(item.id)\n      if (!target) return\n\n      updateActiveId(item.id)\n\n      if (prefersReducedMotion) {\n        target.scrollIntoView({ behavior: \"auto\", block: \"start\" })\n        setOpen(false)\n        return\n      }\n\n      target.scrollIntoView({ behavior: \"smooth\", block: \"start\" })\n\n      // Collapsing the panel while the scroll is in flight cancels it, and the\n      // reader stays where they were having asked to be somewhere else. So the\n      // panel closes once the scroll has landed instead.\n      let settled = false\n      const close = () => {\n        if (settled) return\n        settled = true\n        window.removeEventListener(\"scrollend\", close)\n        window.clearTimeout(timer)\n        setOpen(false)\n      }\n\n      const timer = window.setTimeout(close, 700)\n      window.addEventListener(\"scrollend\", close, { once: true })\n    }\n\n    const activeItem = items.find((item) => item.id === activeId)\n    // At the top there is no section to be in yet, so the label stands.\n    const triggerLabel =\n      showActiveLabel && !open && progress > 0 && activeItem\n        ? activeItem.label\n        : label\n\n    return (\n      <nav\n        {...navProps}\n        aria-label={ariaLabel ?? label}\n        data-slot=\"floating-index\"\n        ref={forwardedRef}\n        className={cn(\n          \"bg-foreground text-background border-background/15 fixed z-50 w-48 max-w-[calc(100vw-2rem)] overflow-hidden rounded-2xl border shadow-lg transition-[width] duration-200 motion-reduce:transition-none\",\n          POSITIONS[position],\n          open && \"w-72\",\n          className\n        )}\n        onKeyDown={(event) => {\n          if (event.key === \"Escape\") {\n            setOpen(false)\n            event.currentTarget.querySelector<HTMLElement>(\"button\")?.focus()\n          }\n        }}\n      >\n        <button\n          data-slot=\"floating-index-trigger\"\n          type=\"button\"\n          aria-controls={contentId}\n          aria-expanded={open}\n          className=\"flex min-h-11 w-full items-center gap-3 rounded-2xl px-3 text-left text-sm font-semibold\"\n          disabled={items.length === 0}\n          onClick={() => setOpen((current) => !current)}\n        >\n          <span className=\"relative flex size-6 shrink-0 items-center justify-center\">\n            <svg\n              aria-hidden=\"true\"\n              className=\"absolute inset-0 -rotate-90\"\n              viewBox=\"0 0 24 24\"\n            >\n              <circle\n                cx=\"12\"\n                cy=\"12\"\n                r=\"9.5\"\n                fill=\"none\"\n                pathLength=\"100\"\n                stroke=\"currentColor\"\n                strokeWidth=\"2\"\n                className=\"opacity-20\"\n              />\n              <circle\n                cx=\"12\"\n                cy=\"12\"\n                r=\"9.5\"\n                fill=\"none\"\n                pathLength=\"100\"\n                stroke=\"currentColor\"\n                strokeDasharray=\"100\"\n                strokeDashoffset={100 - progress * 100}\n                strokeLinecap=\"round\"\n                strokeWidth=\"2\"\n                className=\"transition-[stroke-dashoffset] duration-200 motion-reduce:transition-none\"\n              />\n            </svg>\n            <span className=\"bg-background size-1.5 rounded-full\" />\n          </span>\n\n          <span className=\"min-w-0 flex-1 truncate\">{triggerLabel}</span>\n          <ChevronDown\n            aria-hidden=\"true\"\n            className={cn(\n              \"size-4 shrink-0 transition-transform duration-200 motion-reduce:transition-none\",\n              open && \"rotate-180\"\n            )}\n            strokeWidth={1.8}\n          />\n          <span\n            data-slot=\"floating-index-progress\"\n            className=\"bg-background/10 rounded-full px-2 py-1 text-xs font-bold tabular-nums\"\n          >\n            {Math.round(progress * 100)}%\n          </span>\n        </button>\n\n        <AnimatePresence initial={false}>\n          {open && (\n            <motion.div\n              data-slot=\"floating-index-content\"\n              id={contentId}\n              initial={prefersReducedMotion ? false : { height: 0, opacity: 0 }}\n              animate={{ height: \"auto\", opacity: 1 }}\n              exit={{ height: 0, opacity: 0 }}\n              transition={{ duration: prefersReducedMotion ? 0 : 0.18 }}\n              className=\"overflow-hidden\"\n            >\n              <div className=\"border-background/15 border-t p-2\">\n                {items.map((item) => (\n                  <button\n                    data-slot=\"floating-index-item\"\n                    type=\"button\"\n                    aria-current={activeId === item.id ? \"location\" : undefined}\n                    className={cn(\n                      \"flex min-h-11 w-full items-center gap-3 rounded-xl px-3 text-left text-sm transition-colors duration-150 motion-reduce:transition-none\",\n                      activeId === item.id\n                        ? \"bg-background text-foreground\"\n                        : \"text-background/65 hover:text-background hover:bg-background/10\"\n                    )}\n                    key={item.id}\n                    onClick={() => navigateTo(item)}\n                  >\n                    {item.icon && (\n                      <span className=\"flex size-5 shrink-0 items-center justify-center [&_svg]:size-4\">\n                        {item.icon}\n                      </span>\n                    )}\n                    <span className=\"truncate\">{item.label}</span>\n                  </button>\n                ))}\n              </div>\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </nav>\n    )\n  }\n)\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "navigation",
    "scroll",
    "progress",
    "motion"
  ],
  "type": "registry:ui"
}