{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scroll-progress",
  "title": "Scroll Progress",
  "description": "A scroll progress pill that tracks reading position and expands into a squircle menu of sections you can jump to.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "components/ui/scroll-progress.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  AnimatePresence,\n  motion,\n  useReducedMotion,\n  useScroll,\n  useSpring,\n} from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport type ScrollProgressSection = { id: string; label: string }\n\nconst EASE_IN_OUT = [0.65, 0, 0.35, 1] as const\nconst EASE_OUT = [0.22, 1, 0.36, 1] as const\nconst SIZE_SPRING = { type: \"spring\", bounce: 0.16, duration: 0.5 } as const\nconst LABEL_CROSSFADE = { duration: 0.22, ease: EASE_OUT } as const\nconst LAYER_FADE = { duration: 0.24, ease: EASE_IN_OUT } as const\n\nconst useIsoLayoutEffect =\n  typeof window !== \"undefined\" ? React.useLayoutEffect : React.useEffect\n\ntype Size = { width: number; height: number }\n\nexport type ScrollProgressProps = React.ComponentProps<\"div\"> & {\n  sections?: ScrollProgressSection[]\n  containerRef?: React.RefObject<HTMLElement | null>\n  offset?: number\n}\n\nconst ScrollProgress = ({\n  className,\n  sections = [],\n  containerRef,\n  offset = 120,\n  ...props\n}: ScrollProgressProps) => {\n  const layoutId = React.useId()\n  const reduceMotion = useReducedMotion()\n\n  const { scrollYProgress } = useScroll(\n    containerRef ? { container: containerRef } : undefined\n  )\n  const progress = useSpring(scrollYProgress, {\n    stiffness: 120,\n    damping: 30,\n    mass: 0.3,\n  })\n\n  const [activeId, setActiveId] = React.useState(sections[0]?.id)\n  const [open, setOpen] = React.useState(false)\n\n  const scrollLock = React.useRef(false)\n  const scrollLockTimer = React.useRef<ReturnType<typeof setTimeout>>(undefined)\n\n  React.useEffect(() => {\n    const scroller = containerRef?.current ?? window\n\n    const update = () => {\n      if (scrollLock.current) return\n      const anchor =\n        (containerRef?.current?.getBoundingClientRect().top ?? 0) + offset\n      const active = sections.findLast(({ id }) => {\n        const top = document.getElementById(id)?.getBoundingClientRect().top\n        return top !== undefined && top <= anchor\n      })\n      setActiveId(active?.id ?? sections[0]?.id)\n    }\n\n    update()\n    scroller.addEventListener(\"scroll\", update, { passive: true })\n    window.addEventListener(\"resize\", update)\n    return () => {\n      scroller.removeEventListener(\"scroll\", update)\n      window.removeEventListener(\"resize\", update)\n    }\n  }, [sections, containerRef, offset])\n\n  const label = sections.find((s) => s.id === activeId)?.label\n\n  const labelVersion = React.useRef(0)\n  const prevLabel = React.useRef(label)\n  if (label !== prevLabel.current) {\n    prevLabel.current = label\n    labelVersion.current += 1\n  }\n\n  const collapsedRef = React.useRef<HTMLDivElement>(null)\n  const openRef = React.useRef<HTMLDivElement>(null)\n  const labelRef = React.useRef<HTMLSpanElement>(null)\n  const rootRef = React.useRef<HTMLDivElement>(null)\n\n  const [collapsedSize, setCollapsedSize] = React.useState<Size>()\n  const [openSize, setOpenSize] = React.useState<Size>()\n  const [labelWidth, setLabelWidth] = React.useState<number>()\n\n  useIsoLayoutEffect(() => {\n    const measure = () => {\n      if (labelRef.current) setLabelWidth(labelRef.current.offsetWidth)\n      if (collapsedRef.current) {\n        setCollapsedSize({\n          width: collapsedRef.current.offsetWidth,\n          height: collapsedRef.current.offsetHeight,\n        })\n      }\n      if (openRef.current) {\n        setOpenSize({\n          width: openRef.current.offsetWidth,\n          height: openRef.current.offsetHeight,\n        })\n      }\n    }\n\n    measure()\n    const ro = new ResizeObserver(measure)\n    if (labelRef.current) ro.observe(labelRef.current)\n    if (collapsedRef.current) ro.observe(collapsedRef.current)\n    if (openRef.current) ro.observe(openRef.current)\n    document.fonts?.ready.then(measure).catch(() => {})\n    return () => ro.disconnect()\n  }, [sections])\n\n  React.useEffect(() => {\n    if (!open) return\n    const onPointer = (e: PointerEvent) => {\n      if (!rootRef.current?.contains(e.target as Node)) setOpen(false)\n    }\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key === \"Escape\") setOpen(false)\n    }\n    document.addEventListener(\"pointerdown\", onPointer)\n    document.addEventListener(\"keydown\", onKey)\n    return () => {\n      document.removeEventListener(\"pointerdown\", onPointer)\n      document.removeEventListener(\"keydown\", onKey)\n    }\n  }, [open])\n\n  React.useEffect(() => () => clearTimeout(scrollLockTimer.current), [])\n\n  const selectSection = (id: string) => {\n    scrollLock.current = true\n    clearTimeout(scrollLockTimer.current)\n    scrollLockTimer.current = setTimeout(\n      () => {\n        scrollLock.current = false\n      },\n      reduceMotion ? 0 : 700\n    )\n\n    setActiveId(id)\n    setOpen(false)\n    document.getElementById(id)?.scrollIntoView({\n      behavior: reduceMotion ? \"auto\" : \"smooth\",\n      block: \"start\",\n    })\n  }\n\n  const size = open ? openSize : collapsedSize\n  const radius = open ? 26 : (collapsedSize?.height ?? 32) / 2\n  const squircle = \"[corner-shape:squircle]\"\n\n  return (\n    <div\n      ref={rootRef}\n      data-slot=\"scroll-progress\"\n      className={cn(\"fixed bottom-6 left-1/2 z-50 -translate-x-1/2\", className)}\n      {...props}\n    >\n      <div className=\"pointer-events-none invisible absolute\" aria-hidden>\n        <div\n          ref={collapsedRef}\n          className=\"inline-flex items-center gap-2.5 py-1.5 pl-2 pr-4\"\n        >\n          <span className=\"h-5 w-5\" />\n          <span\n            ref={labelRef}\n            className=\"whitespace-nowrap text-sm font-medium leading-none\"\n          >\n            {label}\n          </span>\n        </div>\n        <div ref={openRef} className=\"w-max p-1.5\">\n          {sections.map((s) => (\n            <div\n              key={s.id}\n              className=\"flex items-center gap-3 px-3 py-2 text-sm font-medium leading-none\"\n            >\n              <span className=\"h-1.5 w-1.5\" />\n              <span className=\"whitespace-nowrap\">{s.label}</span>\n            </div>\n          ))}\n        </div>\n      </div>\n\n      {size && (\n        <motion.div\n          data-slot=\"scroll-progress-surface\"\n          className={cn(\n            \"absolute bottom-0 left-1/2 -translate-x-1/2 overflow-hidden border border-border/60 bg-background/70 shadow-lg backdrop-blur-md\",\n            squircle\n          )}\n          initial={false}\n          animate={{\n            width: size.width,\n            height: size.height,\n            borderRadius: radius,\n          }}\n          transition={reduceMotion ? { duration: 0 } : SIZE_SPRING}\n        >\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            {open ? (\n              <motion.ul\n                key=\"list\"\n                className=\"absolute inset-0 flex flex-col p-1.5\"\n                initial={{\n                  opacity: 0,\n                  filter: reduceMotion ? undefined : \"blur(4px)\",\n                }}\n                animate={{ opacity: 1, filter: \"blur(0px)\" }}\n                exit={{\n                  opacity: 0,\n                  filter: reduceMotion ? undefined : \"blur(4px)\",\n                }}\n                transition={LAYER_FADE}\n              >\n                {sections.map((s, i) => {\n                  const isActive = s.id === activeId\n                  return (\n                    <li key={s.id}>\n                      <button\n                        type=\"button\"\n                        onClick={() => selectSection(s.id)}\n                        className={cn(\n                          \"relative flex w-full items-center gap-3 rounded-[14px] px-3 py-2 text-left text-sm font-medium leading-none transition-colors\",\n                          squircle,\n                          isActive\n                            ? \"text-foreground\"\n                            : \"text-foreground/55 hover:text-foreground/80\"\n                        )}\n                      >\n                        {isActive && (\n                          <motion.span\n                            layoutId={`${layoutId}-active`}\n                            className={cn(\n                              \"absolute inset-0 rounded-[14px] bg-foreground/10\",\n                              squircle\n                            )}\n                            transition={\n                              reduceMotion ? { duration: 0 } : SIZE_SPRING\n                            }\n                          />\n                        )}\n                        <motion.span\n                          className={cn(\n                            \"relative h-1.5 w-1.5 shrink-0 rounded-full\",\n                            isActive ? \"bg-foreground\" : \"bg-foreground/30\"\n                          )}\n                          initial={\n                            reduceMotion\n                              ? undefined\n                              : { opacity: 0, y: 4, filter: \"blur(3px)\" }\n                          }\n                          animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n                          transition={{\n                            duration: 0.3,\n                            ease: EASE_IN_OUT,\n                            delay: reduceMotion ? 0 : 0.04 + i * 0.03,\n                          }}\n                        />\n                        <motion.span\n                          className=\"relative whitespace-nowrap\"\n                          initial={\n                            reduceMotion\n                              ? undefined\n                              : { opacity: 0, y: 4, filter: \"blur(3px)\" }\n                          }\n                          animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n                          transition={{\n                            duration: 0.3,\n                            ease: EASE_IN_OUT,\n                            delay: reduceMotion ? 0 : 0.04 + i * 0.03,\n                          }}\n                        >\n                          {s.label}\n                        </motion.span>\n                      </button>\n                    </li>\n                  )\n                })}\n              </motion.ul>\n            ) : (\n              <motion.button\n                key=\"pill\"\n                type=\"button\"\n                onClick={() => setOpen(true)}\n                aria-label=\"Show sections\"\n                className=\"absolute inset-0 flex items-center gap-2.5 py-1.5 pl-2 pr-4\"\n                initial={{\n                  opacity: 0,\n                  filter: reduceMotion ? undefined : \"blur(4px)\",\n                }}\n                animate={{ opacity: 1, filter: \"blur(0px)\" }}\n                exit={{\n                  opacity: 0,\n                  filter: reduceMotion ? undefined : \"blur(4px)\",\n                }}\n                transition={LAYER_FADE}\n              >\n                <span className=\"shrink-0\">\n                  <svg viewBox=\"0 0 24 24\" className=\"h-5 w-5 -rotate-90\" aria-hidden>\n                    <circle\n                      cx=\"12\"\n                      cy=\"12\"\n                      r=\"10\"\n                      fill=\"none\"\n                      strokeWidth=\"2.5\"\n                      className=\"stroke-foreground/15\"\n                    />\n                    <motion.circle\n                      cx=\"12\"\n                      cy=\"12\"\n                      r=\"10\"\n                      fill=\"none\"\n                      strokeWidth=\"2.5\"\n                      strokeLinecap=\"round\"\n                      className=\"stroke-foreground\"\n                      style={{ pathLength: progress }}\n                    />\n                  </svg>\n                </span>\n\n                <span\n                  className=\"relative h-5 shrink-0\"\n                  style={{ width: labelWidth }}\n                >\n                  <AnimatePresence initial={false}>\n                    {label && (\n                      <motion.span\n                        key={labelVersion.current}\n                        data-slot=\"scroll-progress-label\"\n                        className=\"absolute inset-y-0 left-0 flex items-center whitespace-nowrap text-sm font-medium leading-none text-foreground\"\n                        initial={\n                          reduceMotion\n                            ? { opacity: 0 }\n                            : { opacity: 0, filter: \"blur(1.5px)\" }\n                        }\n                        animate={{ opacity: 1, filter: \"blur(0px)\" }}\n                        exit={\n                          reduceMotion\n                            ? { opacity: 0 }\n                            : { opacity: 0, filter: \"blur(1.5px)\" }\n                        }\n                        transition={LABEL_CROSSFADE}\n                      >\n                        {label}\n                      </motion.span>\n                    )}\n                  </AnimatePresence>\n                </span>\n              </motion.button>\n            )}\n          </AnimatePresence>\n        </motion.div>\n      )}\n    </div>\n  )\n}\n\nexport { ScrollProgress }\nexport default ScrollProgress\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}