Anchor
React navigationIn-page navigation list with scroll-spy. Renders a vertical list of links; the active item highlights as the user scrolls through anchored sections.
Also available for Vue ->Installation
$ pnpm dlx shadcn@latest add https://react.uipkge.dev/r/react/anchor.json$ npx shadcn@latest add https://react.uipkge.dev/r/react/anchor.json$ yarn dlx shadcn@latest add https://react.uipkge.dev/r/react/anchor.json$ bunx shadcn@latest add https://react.uipkge.dev/r/react/anchor.json
Or with the named registry:
npx shadcn@latest add @uipkge-react/anchor
Examples
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
items | AnchorItem[] | — | optional |
offsetTop | number | 0 | optional |
bounds | number | 5 | optional |
scrollContainer | HTMLElement | string | null | — | optional |
affix | boolean | false | optional |
onChange | (href: string) => void | — | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
AnchorItem interface AnchorItem {
href: string
title: string
children?: AnchorItem[]
} AnchorContextValue interface AnchorContextValue {
activeHref: string | null
setActive: (href: string) => void
register: (href: string) => void
unregister: (href: string) => void
scrollContainerRef: React.MutableRefObject<HTMLElement | Window>
offsetTopRef: React.MutableRefObject<number>
} Files (2)
-
components/ui/anchor/anchor.tsx 7.6 kB
'use client' import * as React from 'react' import { cn } from '@/lib/utils' export interface AnchorItem { href: string title: string children?: AnchorItem[] } interface AnchorContextValue { activeHref: string | null setActive: (href: string) => void register: (href: string) => void unregister: (href: string) => void scrollContainerRef: React.MutableRefObject<HTMLElement | Window> offsetTopRef: React.MutableRefObject<number> } const AnchorContext = React.createContext<AnchorContextValue | null>(null) export interface AnchorProps extends Omit<React.HTMLAttributes<HTMLElement>, 'onChange'> { items?: AnchorItem[] offsetTop?: number bounds?: number scrollContainer?: HTMLElement | string | null affix?: boolean onChange?: (href: string) => void } const Anchor = React.forwardRef<HTMLElement, AnchorProps>( ( { className, items = [], offsetTop = 0, // eslint-disable-next-line @typescript-eslint/no-unused-vars bounds = 5, scrollContainer = null, affix = false, onChange, style, children, ...props }, ref, ) => { const [activeHref, setActiveHref] = React.useState<string | null>(null) const activeHrefRef = React.useRef<string | null>(null) const registeredRef = React.useRef<Set<string>>(new Set()) const observerRef = React.useRef<IntersectionObserver | null>(null) const observedRef = React.useRef<Map<string, Element>>(new Map()) const scrollContainerRef = React.useRef<HTMLElement | Window>( typeof window !== 'undefined' ? window : (null as unknown as Window), ) const offsetTopRef = React.useRef(offsetTop) const onChangeRef = React.useRef(onChange) React.useEffect(() => { offsetTopRef.current = offsetTop }, [offsetTop]) React.useEffect(() => { onChangeRef.current = onChange }, [onChange]) const setActive = React.useCallback((href: string) => { activeHrefRef.current = href setActiveHref(href) onChangeRef.current?.(href) }, []) const resolveContainer = React.useCallback((): HTMLElement | Window => { if (!scrollContainer) return window if (typeof scrollContainer === 'string') { return (document.querySelector(scrollContainer) as HTMLElement) ?? window } return scrollContainer }, [scrollContainer]) const rebuildObserver = React.useCallback(() => { if (observerRef.current) { observerRef.current.disconnect() observedRef.current.clear() } const container = scrollContainerRef.current const root = container === window ? null : (container as HTMLElement) observerRef.current = new IntersectionObserver( (entries) => { const intersecting = entries.filter((e) => e.isIntersecting) if (intersecting.length === 0) return intersecting.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top) const id = '#' + intersecting[0]!.target.id if (id !== activeHrefRef.current) { activeHrefRef.current = id setActiveHref(id) onChangeRef.current?.(id) } }, { root, rootMargin: `-${offsetTopRef.current}px 0px -60% 0px`, threshold: [0, 1], }, ) for (const href of registeredRef.current) { const el = document.querySelector(href) if (el) { observedRef.current.set(href, el) observerRef.current.observe(el) } } }, []) const register = React.useCallback((href: string) => { registeredRef.current.add(href) if (!observerRef.current) return const el = document.querySelector(href) if (el && !observedRef.current.has(href)) { observedRef.current.set(href, el) observerRef.current.observe(el) } }, []) const unregister = React.useCallback((href: string) => { registeredRef.current.delete(href) const el = observedRef.current.get(href) if (el && observerRef.current) observerRef.current.unobserve(el) observedRef.current.delete(href) }, []) React.useEffect(() => { scrollContainerRef.current = resolveContainer() rebuildObserver() return () => { observerRef.current?.disconnect() observerRef.current = null } // eslint-disable-next-line react-hooks/exhaustive-deps }, [scrollContainer]) const ctx = React.useMemo<AnchorContextValue>( () => ({ activeHref, setActive, register, unregister, scrollContainerRef, offsetTopRef }), [activeHref, setActive, register, unregister], ) return ( <AnchorContext.Provider value={ctx}> <nav ref={ref} data-uipkge="" data-slot="anchor" aria-label="Table of contents" className={cn('border-border flex flex-col gap-1 border-l text-sm', affix && 'sticky', className)} style={affix ? { top: `${offsetTop}px`, ...style } : style} {...props} > {items.length ? items.map((item) => ( <AnchorLink key={item.href} href={item.href} title={item.title}> {(item.children ?? []).map((child) => ( <AnchorLink key={child.href} href={child.href} title={child.title} /> ))} </AnchorLink> )) : children} </nav> </AnchorContext.Provider> ) }, ) Anchor.displayName = 'Anchor' export interface AnchorLinkProps { href: string title: string className?: string children?: React.ReactNode } const AnchorLink = React.forwardRef<HTMLDivElement, AnchorLinkProps>( ({ href, title, className, children }, ref) => { const ctx = React.useContext(AnchorContext) if (!ctx) throw new Error('AnchorLink must be used inside <Anchor>.') const isActive = ctx.activeHref === href React.useEffect(() => { ctx.register(href) return () => ctx.unregister(href) // eslint-disable-next-line react-hooks/exhaustive-deps }, [href]) const onClick = (e: React.MouseEvent) => { e.preventDefault() const el = document.querySelector(href) as HTMLElement | null if (!el) return const offset = ctx.offsetTopRef.current const container = ctx.scrollContainerRef.current const smooth = !window.matchMedia('(prefers-reduced-motion: reduce)').matches const behavior: ScrollBehavior = smooth ? 'smooth' : 'auto' if (container === window) { const top = el.getBoundingClientRect().top + window.scrollY - offset window.scrollTo({ top, behavior }) } else { const c = container as HTMLElement const top = el.getBoundingClientRect().top - c.getBoundingClientRect().top + c.scrollTop - offset c.scrollTo({ top, behavior }) } history.replaceState(null, '', href) ctx.setActive(href) } return ( <div ref={ref} data-uipkge="" data-slot="anchor-link" className="flex flex-col"> <a href={href} aria-current={isActive ? 'location' : undefined} className={cn( 'text-muted-foreground -ml-px block border-l-2 border-transparent py-2 pl-3 transition-colors', 'hover:text-foreground', 'focus-visible:ring-ring rounded-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none', isActive && 'border-primary text-foreground font-medium', className, )} onClick={onClick} > {title} </a> {children ? <div className="ml-3">{children}</div> : null} </div> ) }, ) AnchorLink.displayName = 'AnchorLink' export { Anchor, AnchorLink } -
components/ui/anchor/index.ts 0.1 kB
export { Anchor, AnchorLink, type AnchorItem, type AnchorProps, type AnchorLinkProps } from './anchor'
Raw manifest: https://react.uipkge.dev/r/react/anchor.json