{ "name": "scroll-spy", "type": "registry:ui", "files": [ { "path": "ui/scroll-spy.tsx", "type": "registry:ui", "content": "\"use client\";\n\nimport { mergeProps } from \"@base-ui/react/merge-props\";\nimport { useRender } from \"@base-ui/react/use-render\";\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { useAsRef } from \"@/registry/bases/base/hooks/use-as-ref\";\nimport { useIsomorphicLayoutEffect } from \"@/registry/bases/base/hooks/use-isomorphic-layout-effect\";\nimport { useLazyRef } from \"@/registry/bases/base/hooks/use-lazy-ref\";\nimport { useComposedRefs } from \"@/registry/bases/base/lib/compose-refs\";\nimport { useDirection } from \"@/registry/bases/base/ui/direction\";\n\nconst ROOT_NAME = \"ScrollSpy\";\nconst NAV_NAME = \"ScrollSpyNav\";\nconst LINK_NAME = \"ScrollSpyLink\";\nconst VIEWPORT_NAME = \"ScrollSpyViewport\";\nconst SECTION_NAME = \"ScrollSpySection\";\n\ntype Direction = \"ltr\" | \"rtl\";\ntype Orientation = \"horizontal\" | \"vertical\";\n\ntype LinkElement = HTMLAnchorElement;\ntype SectionElement = HTMLDivElement;\n\nfunction getDefaultScrollBehavior(): ScrollBehavior {\n if (typeof window === \"undefined\") return \"smooth\";\n return window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n ? \"auto\"\n : \"smooth\";\n}\n\ninterface StoreState {\n value: string;\n}\n\ninterface Store {\n subscribe: (callback: () => void) => () => void;\n getState: () => StoreState;\n setState: (key: K, value: StoreState[K]) => void;\n notify: () => void;\n}\n\nconst StoreContext = React.createContext(null);\n\nfunction useStore(\n selector: (state: StoreState) => T,\n ogStore?: Store | null,\n): T {\n const contextStore = React.useContext(StoreContext);\n\n const store = ogStore ?? contextStore;\n\n if (!store) {\n throw new Error(`\\`useStore\\` must be used within \\`${ROOT_NAME}\\``);\n }\n\n const getSnapshot = React.useCallback(\n () => selector(store.getState()),\n [store, selector],\n );\n\n return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);\n}\n\ninterface ScrollSpyContextValue {\n offset: number;\n scrollBehavior: ScrollBehavior;\n dir: Direction;\n orientation: Orientation;\n scrollContainer: HTMLElement | null;\n isScrollingRef: React.RefObject;\n onSectionRegister: (id: string, element: SectionElement) => void;\n onSectionUnregister: (id: string) => void;\n onScrollToSection: (sectionId: string) => void;\n}\n\nconst ScrollSpyContext = React.createContext(\n null,\n);\n\nfunction useScrollSpyContext(consumerName: string) {\n const context = React.useContext(ScrollSpyContext);\n if (!context) {\n throw new Error(`\\`${consumerName}\\` must be used within \\`${ROOT_NAME}\\``);\n }\n return context;\n}\n\ninterface ScrollSpyProps\n extends React.ComponentProps<\"div\">,\n useRender.ComponentProps<\"div\"> {\n value?: string;\n defaultValue?: string;\n onValueChange?: (value: string) => void;\n rootMargin?: string;\n threshold?: number | number[];\n offset?: number;\n scrollBehavior?: ScrollBehavior;\n scrollContainer?: HTMLElement | null;\n dir?: Direction;\n orientation?: Orientation;\n}\n\nfunction ScrollSpy(props: ScrollSpyProps) {\n const {\n value,\n defaultValue,\n onValueChange,\n rootMargin,\n threshold = 0.1,\n offset = 0,\n scrollBehavior = getDefaultScrollBehavior(),\n scrollContainer = null,\n dir: dirProp,\n orientation = \"horizontal\",\n render,\n className,\n ...rootProps\n } = props;\n\n const contextDir = useDirection();\n const dir = dirProp ?? contextDir;\n\n const stateRef = useLazyRef(() => ({\n value: value ?? defaultValue ?? \"\",\n }));\n const listenersRef = useLazyRef(() => new Set<() => void>());\n const onValueChangeRef = useAsRef(onValueChange);\n\n const store = React.useMemo(() => {\n return {\n subscribe: (cb) => {\n listenersRef.current.add(cb);\n return () => listenersRef.current.delete(cb);\n },\n getState: () => {\n return stateRef.current;\n },\n setState: (key, value) => {\n if (Object.is(stateRef.current[key], value)) return;\n\n stateRef.current[key] = value;\n\n if (key === \"value\" && value) {\n onValueChangeRef.current?.(value);\n }\n\n store.notify();\n },\n notify: () => {\n for (const cb of listenersRef.current) {\n cb();\n }\n },\n };\n }, [listenersRef, stateRef, onValueChangeRef]);\n\n const sectionMapRef = React.useRef(new Map());\n const isScrollingRef = React.useRef(false);\n const rafIdRef = React.useRef(null);\n const isMountedRef = React.useRef(false);\n const scrollTimeoutRef = React.useRef(null);\n\n const onSectionRegister = React.useCallback(\n (id: string, element: SectionElement) => {\n sectionMapRef.current.set(id, element);\n },\n [],\n );\n\n const onSectionUnregister = React.useCallback((id: string) => {\n sectionMapRef.current.delete(id);\n }, []);\n\n const onScrollToSection = React.useCallback(\n (sectionId: string) => {\n const section = scrollContainer\n ? scrollContainer.querySelector(`#${sectionId}`)\n : document.getElementById(sectionId);\n\n if (!section) {\n store.setState(\"value\", sectionId);\n return;\n }\n\n // Set flag to prevent observer from firing during programmatic scroll\n isScrollingRef.current = true;\n store.setState(\"value\", sectionId);\n\n if (scrollContainer) {\n const containerRect = scrollContainer.getBoundingClientRect();\n const sectionRect = section.getBoundingClientRect();\n const scrollTop = scrollContainer.scrollTop;\n const offsetPosition =\n sectionRect.top - containerRect.top + scrollTop - offset;\n\n scrollContainer.scrollTo({\n top: offsetPosition,\n behavior: scrollBehavior,\n });\n } else {\n const sectionPosition = section.getBoundingClientRect().top;\n const offsetPosition = sectionPosition + window.scrollY - offset;\n\n window.scrollTo({\n top: offsetPosition,\n behavior: scrollBehavior,\n });\n }\n\n if (scrollTimeoutRef.current !== null) {\n clearTimeout(scrollTimeoutRef.current);\n }\n\n scrollTimeoutRef.current = window.setTimeout(() => {\n isScrollingRef.current = false;\n }, 500);\n },\n [scrollContainer, offset, scrollBehavior, store],\n );\n\n useIsomorphicLayoutEffect(() => {\n const currentValue = value ?? defaultValue;\n if (currentValue === undefined) return;\n\n if (!isMountedRef.current) {\n isMountedRef.current = true;\n store.setState(\"value\", currentValue);\n return;\n }\n\n onScrollToSection(currentValue);\n }, [value, onScrollToSection]);\n\n useIsomorphicLayoutEffect(() => {\n const sectionMap = sectionMapRef.current;\n if (sectionMap.size === 0) return;\n\n const observerRootMargin = rootMargin ?? `${-offset}px 0px -70% 0px`;\n\n const observer = new IntersectionObserver(\n (entries) => {\n if (isScrollingRef.current) return;\n\n if (rafIdRef.current !== null) {\n cancelAnimationFrame(rafIdRef.current);\n }\n\n rafIdRef.current = requestAnimationFrame(() => {\n const intersecting = entries.filter((entry) => entry.isIntersecting);\n\n if (intersecting.length === 0) return;\n\n const topmost = intersecting.reduce((prev, curr) => {\n return curr.boundingClientRect.top < prev.boundingClientRect.top\n ? curr\n : prev;\n });\n\n const id = topmost.target.id;\n if (id && sectionMap.has(id)) {\n store.setState(\"value\", id);\n }\n });\n },\n {\n root: scrollContainer,\n rootMargin: observerRootMargin,\n threshold,\n },\n );\n\n for (const element of sectionMap.values()) {\n observer.observe(element);\n }\n\n return () => {\n observer.disconnect();\n if (rafIdRef.current !== null) {\n cancelAnimationFrame(rafIdRef.current);\n }\n if (scrollTimeoutRef.current !== null) {\n clearTimeout(scrollTimeoutRef.current);\n }\n };\n }, [offset, rootMargin, threshold, scrollContainer]);\n\n const contextValue = React.useMemo(\n () => ({\n dir,\n orientation,\n offset,\n scrollBehavior,\n scrollContainer,\n isScrollingRef,\n onSectionRegister,\n onSectionUnregister,\n onScrollToSection,\n }),\n [\n dir,\n orientation,\n offset,\n scrollBehavior,\n scrollContainer,\n onSectionRegister,\n onSectionUnregister,\n onScrollToSection,\n ],\n );\n\n const element = useRender({\n defaultTagName: \"div\",\n props: mergeProps<\"div\">(\n {\n dir,\n className: cn(\n \"flex\",\n orientation === \"horizontal\" ? \"flex-row\" : \"flex-col\",\n className,\n ),\n },\n rootProps,\n ),\n render,\n state: {\n slot: \"scroll-spy\",\n orientation,\n },\n });\n\n return (\n \n \n {element}\n \n \n );\n}\n\ninterface ScrollSpyNavProps\n extends React.ComponentProps<\"nav\">,\n useRender.ComponentProps<\"nav\"> {}\n\nfunction ScrollSpyNav(props: ScrollSpyNavProps) {\n const { render, className, ...navProps } = props;\n\n const { dir, orientation } = useScrollSpyContext(NAV_NAME);\n\n return useRender({\n defaultTagName: \"nav\",\n props: mergeProps<\"nav\">(\n {\n dir,\n className: cn(\n \"flex gap-2\",\n orientation === \"horizontal\" ? \"flex-col\" : \"flex-row\",\n className,\n ),\n },\n navProps,\n ),\n render,\n state: {\n slot: \"scroll-spy-nav\",\n orientation,\n },\n });\n}\n\ninterface ScrollSpyLinkProps\n extends React.ComponentProps<\"a\">,\n useRender.ComponentProps<\"a\"> {\n value: string;\n}\n\nfunction ScrollSpyLink(props: ScrollSpyLinkProps) {\n const { value: linkValue, render, onClick, className, ...linkProps } = props;\n\n const { orientation, onScrollToSection } = useScrollSpyContext(LINK_NAME);\n const value = useStore((state) => state.value);\n const isActive = value === linkValue;\n\n const onLinkClick = React.useCallback(\n (event: React.MouseEvent) => {\n event.preventDefault();\n onClick?.(event);\n onScrollToSection(linkValue);\n },\n [linkValue, onClick, onScrollToSection],\n );\n\n return useRender({\n defaultTagName: \"a\",\n props: mergeProps<\"a\">(\n {\n href: `#${linkValue}`,\n className: cn(\n \"rounded px-3 py-1.5 font-medium text-muted-foreground text-sm transition-colors hover:bg-accent hover:text-accent-foreground data-[state=active]:bg-accent data-[state=active]:text-foreground\",\n className,\n ),\n onClick: onLinkClick,\n },\n linkProps,\n ),\n render,\n state: {\n slot: \"scroll-spy-link\",\n orientation,\n state: isActive ? \"active\" : \"inactive\",\n },\n });\n}\n\ninterface ScrollSpyViewportProps\n extends React.ComponentProps<\"div\">,\n useRender.ComponentProps<\"div\"> {}\n\nfunction ScrollSpyViewport(props: ScrollSpyViewportProps) {\n const { render, className, ...viewportProps } = props;\n\n const { dir, orientation } = useScrollSpyContext(VIEWPORT_NAME);\n\n return useRender({\n defaultTagName: \"div\",\n props: mergeProps<\"div\">(\n {\n dir,\n className: cn(\"flex flex-1 flex-col gap-8\", className),\n },\n viewportProps,\n ),\n render,\n state: {\n slot: \"scroll-spy-viewport\",\n orientation,\n },\n });\n}\n\ninterface ScrollSpySectionProps\n extends React.ComponentProps<\"div\">,\n useRender.ComponentProps<\"div\"> {\n value: string;\n}\n\nfunction ScrollSpySection(props: ScrollSpySectionProps) {\n const { render, ref, value, ...sectionProps } = props;\n\n const { orientation, onSectionRegister, onSectionUnregister } =\n useScrollSpyContext(SECTION_NAME);\n const sectionRef = React.useRef(null);\n const composedRef = useComposedRefs(ref, sectionRef);\n\n useIsomorphicLayoutEffect(() => {\n const element = sectionRef.current;\n if (!element || !value) return;\n\n onSectionRegister(value, element);\n\n return () => {\n onSectionUnregister(value);\n };\n }, [value, onSectionRegister, onSectionUnregister]);\n\n return useRender({\n defaultTagName: \"div\",\n props: mergeProps<\"div\">(\n {\n id: value,\n ref: composedRef,\n },\n sectionProps,\n ),\n render,\n state: {\n slot: \"scroll-spy-section\",\n orientation,\n },\n });\n}\n\nexport {\n ScrollSpy,\n ScrollSpyLink,\n ScrollSpyNav,\n type ScrollSpyProps,\n ScrollSpySection,\n ScrollSpyViewport,\n};\n", "target": "" }, { "path": "lib/compose-refs.ts", "type": "registry:lib", "content": "/**\n * @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/compose-refs.tsx\n */\n\nimport * as React from \"react\";\n\ntype PossibleRef = React.Ref | undefined;\n\n/**\n * Set a given ref to a given value\n * This utility takes care of different types of refs: callback refs and RefObject(s)\n */\nfunction setRef(ref: PossibleRef, value: T) {\n if (typeof ref === \"function\") {\n return ref(value);\n }\n\n if (ref !== null && ref !== undefined) {\n ref.current = value;\n }\n}\n\n/**\n * A utility to compose multiple refs together\n * Accepts callback refs and RefObject(s)\n */\nfunction composeRefs(...refs: PossibleRef[]): React.RefCallback {\n return (node) => {\n let hasCleanup = false;\n const cleanups = refs.map((ref) => {\n const cleanup = setRef(ref, node);\n if (!hasCleanup && typeof cleanup === \"function\") {\n hasCleanup = true;\n }\n return cleanup;\n });\n\n // React <19 will log an error to the console if a callback ref returns a\n // value. We don't use ref cleanups internally so this will only happen if a\n // user's ref callback returns a value, which we only expect if they are\n // using the cleanup functionality added in React 19.\n if (hasCleanup) {\n return () => {\n for (let i = 0; i < cleanups.length; i++) {\n const cleanup = cleanups[i];\n if (typeof cleanup === \"function\") {\n cleanup();\n } else {\n setRef(refs[i], null);\n }\n }\n };\n }\n };\n}\n\n/**\n * A custom hook that composes multiple refs\n * Accepts callback refs and RefObject(s)\n */\nfunction useComposedRefs(...refs: PossibleRef[]): React.RefCallback {\n // biome-ignore lint/correctness/useExhaustiveDependencies: we want to memoize by all values\n return React.useCallback(composeRefs(...refs), refs);\n}\n\nexport { composeRefs, useComposedRefs };\n", "target": "" } ], "registryDependencies": [ "direction", "@diceui/use-as-ref", "@diceui/use-isomorphic-layout-effect", "@diceui/use-lazy-ref" ], "dependencies": [ "@base-ui/react" ] }