{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "proximity-sidebar", "title": "Proximity Sidebar", "description": "A dash-style scroll sidebar whose items expand as the pointer approaches.", "dependencies": [ "motion" ], "registryDependencies": [ "amitgajare2/ariseui/utils" ], "files": [ { "path": "components/ui/proximity-sidebar.tsx", "content": "\"use client\"\r\n\r\nimport React, {\r\n useCallback,\r\n useEffect,\r\n useMemo,\r\n useRef,\r\n useState,\r\n} from \"react\"\r\nimport {\r\n motion,\r\n useMotionValue,\r\n useReducedMotion,\r\n useSpring,\r\n useTransform,\r\n type MotionValue,\r\n} from \"motion/react\"\r\n\r\ntype Side = \"left\" | \"right\"\r\ntype SectionKind = \"title\" | \"subtitle\" | \"section\" | \"body\"\r\ntype SectionLevel = 1 | 2 | 3 | 4 | 5 | 6\r\n\r\nexport type ProximitySection = {\r\n id: string\r\n label: string\r\n kind?: SectionKind\r\n level?: SectionLevel\r\n}\r\n\r\ntype DashPreset = {\r\n base: number\r\n bump: number\r\n thickness: number\r\n className: string\r\n}\r\n\r\ntype DashProps = {\r\n active: boolean\r\n mouseY: MotionValue\r\n onSelect: (id: string) => void\r\n registerDash: (id: string, node: HTMLButtonElement | null) => void\r\n section: ProximitySection\r\n sectionKind: SectionKind\r\n side: Side\r\n}\r\n\r\ntype ProximitySidebarProps = {\r\n activeOffset?: number\r\n className?: string\r\n sections: ProximitySection[]\r\n side?: Side\r\n}\r\n\r\nconst RADIUS = 40\r\nconst MAX_DASH_WIDTH = 110\r\nconst SCROLL_IDLE_RESET_DELAY = 80\r\n\r\nconst DASH_PRESETS: Record = {\r\n title: {\r\n base: 40,\r\n bump: 70,\r\n thickness: 1,\r\n className: \"bg-foreground\",\r\n },\r\n subtitle: {\r\n base: 36,\r\n bump: 64,\r\n thickness: 1,\r\n className: \"bg-foreground\",\r\n },\r\n section: {\r\n base: 30,\r\n bump: 56,\r\n thickness: 1,\r\n className: \"bg-foreground/60 dark:bg-muted-foreground/40\",\r\n },\r\n body: {\r\n base: 24,\r\n bump: 50,\r\n thickness: 1,\r\n className: \"bg-foreground/60 dark:bg-muted-foreground/40\",\r\n },\r\n}\r\n\r\nconst getSectionElement = (id: string) =>\r\n typeof document === \"undefined\" ? null : document.getElementById(id)\r\n\r\nconst getSectionKind = (section: ProximitySection): SectionKind => {\r\n if (section.kind) return section.kind\r\n if (section.level === 1) return \"title\"\r\n if (section.level === 2) return \"subtitle\"\r\n if (section.level === 3) return \"section\"\r\n return \"body\"\r\n}\r\n\r\nconst getElementSectionKind = (id: string): SectionKind | undefined => {\r\n const heading = getSectionElement(id)?.querySelector(\"h1, h2, h3, h4, h5, h6\")\r\n const tagName = heading?.tagName.toLowerCase()\r\n\r\n if (tagName === \"h1\") return \"title\"\r\n if (tagName === \"h2\") return \"subtitle\"\r\n if (tagName === \"h3\") return \"section\"\r\n if (tagName) return \"body\"\r\n}\r\n\r\nconst getScrollParent = (element: HTMLElement) => {\r\n let parent = element.parentElement\r\n\r\n while (parent) {\r\n const { overflowY } = window.getComputedStyle(parent)\r\n\r\n if (/(auto|scroll|overlay)/.test(overflowY)) {\r\n return parent\r\n }\r\n\r\n parent = parent.parentElement\r\n }\r\n\r\n return window\r\n}\r\n\r\nconst Dash = ({\r\n active,\r\n mouseY,\r\n onSelect,\r\n registerDash,\r\n section,\r\n sectionKind,\r\n side,\r\n}: DashProps) => {\r\n const ref = useRef(null)\r\n const preset = DASH_PRESETS[sectionKind]\r\n const activeWidth = preset.base + preset.bump\r\n\r\n useEffect(() => {\r\n registerDash(section.id, ref.current)\r\n return () => registerDash(section.id, null)\r\n }, [registerDash, section.id])\r\n\r\n const distance = useTransform(mouseY, (y) => {\r\n const rect = ref.current?.getBoundingClientRect()\r\n if (!rect) return RADIUS\r\n return y - (rect.top + rect.height / 2)\r\n })\r\n\r\n const targetScaleX = useTransform(\r\n distance,\r\n [-RADIUS, 0, RADIUS],\r\n [\r\n preset.base / MAX_DASH_WIDTH,\r\n activeWidth / MAX_DASH_WIDTH,\r\n preset.base / MAX_DASH_WIDTH,\r\n ],\r\n { clamp: true }\r\n )\r\n\r\n const scaleX = useSpring(targetScaleX, {\r\n stiffness: 320,\r\n damping: 34,\r\n mass: 0.7,\r\n })\r\n\r\n return (\r\n onSelect(section.id)}\r\n >\r\n \r\n \r\n )\r\n}\r\n\r\nconst ProximitySidebar = ({\r\n activeOffset = 0.4,\r\n className = \"\",\r\n side = \"left\",\r\n sections,\r\n}: ProximitySidebarProps) => {\r\n const mouseY = useMotionValue(Infinity)\r\n const shouldReduceMotion = useReducedMotion()\r\n const dashRefs = useRef(new Map())\r\n const pointerInside = useRef(false)\r\n const resetTimer = useRef(null)\r\n const [activeId, setActiveId] = useState(sections[0]?.id)\r\n const [detectedKinds, setDetectedKinds] = useState>(\r\n {}\r\n )\r\n\r\n const sectionIds = useMemo(\r\n () => sections.map((section) => section.id).join(\"|\"),\r\n [sections]\r\n )\r\n\r\n const registerDash = useCallback(\r\n (id: string, node: HTMLButtonElement | null) => {\r\n if (node) {\r\n dashRefs.current.set(id, node)\r\n return\r\n }\r\n\r\n dashRefs.current.delete(id)\r\n },\r\n []\r\n )\r\n\r\n const clearPendingReset = useCallback(() => {\r\n if (!resetTimer.current) return\r\n\r\n window.clearTimeout(resetTimer.current)\r\n resetTimer.current = null\r\n }, [])\r\n\r\n const setMouseToDash = useCallback(\r\n (id?: string) => {\r\n if (!id) {\r\n mouseY.set(Infinity)\r\n return\r\n }\r\n\r\n const node = dashRefs.current.get(id)\r\n if (!node) return\r\n\r\n const rect = node.getBoundingClientRect()\r\n mouseY.set(rect.top + rect.height / 2)\r\n },\r\n [mouseY]\r\n )\r\n\r\n const pulseDash = useCallback(\r\n (id?: string) => {\r\n setMouseToDash(id)\r\n clearPendingReset()\r\n\r\n if (!id || pointerInside.current) return\r\n\r\n resetTimer.current = window.setTimeout(() => {\r\n mouseY.set(Infinity)\r\n resetTimer.current = null\r\n }, SCROLL_IDLE_RESET_DELAY)\r\n },\r\n [clearPendingReset, mouseY, setMouseToDash]\r\n )\r\n\r\n const selectSection = useCallback(\r\n (id: string) => {\r\n const element = getSectionElement(id)\r\n if (!element) return\r\n\r\n element.scrollIntoView({\r\n behavior: shouldReduceMotion ? \"auto\" : \"smooth\",\r\n block: \"start\",\r\n })\r\n\r\n window.history.replaceState(null, \"\", `#${id}`)\r\n setActiveId(id)\r\n pulseDash(id)\r\n },\r\n [pulseDash, shouldReduceMotion]\r\n )\r\n\r\n useEffect(() => () => clearPendingReset(), [clearPendingReset])\r\n\r\n useEffect(() => {\r\n const kinds = sections.reduce>(\r\n (nextKinds, section) => {\r\n nextKinds[section.id] =\r\n section.kind || section.level\r\n ? getSectionKind(section)\r\n : getElementSectionKind(section.id) ?? getSectionKind(section)\r\n\r\n return nextKinds\r\n },\r\n {}\r\n )\r\n\r\n setDetectedKinds(kinds)\r\n }, [sectionIds, sections])\r\n\r\n useEffect(() => {\r\n if (!sections.length) return\r\n\r\n let frame = 0\r\n\r\n const updateActiveSection = () => {\r\n frame = 0\r\n\r\n const anchorY = window.innerHeight * activeOffset\r\n let nextActiveId = sections[0]?.id\r\n let shortestDistance = Number.POSITIVE_INFINITY\r\n\r\n for (const section of sections) {\r\n const element = getSectionElement(section.id)\r\n if (!element) continue\r\n\r\n const rect = element.getBoundingClientRect()\r\n const containsAnchor = rect.top <= anchorY && rect.bottom >= anchorY\r\n const distance = containsAnchor\r\n ? 0\r\n : Math.min(Math.abs(rect.top - anchorY), Math.abs(rect.bottom - anchorY))\r\n\r\n if (distance < shortestDistance) {\r\n shortestDistance = distance\r\n nextActiveId = section.id\r\n }\r\n }\r\n\r\n setActiveId(nextActiveId)\r\n\r\n if (!pointerInside.current) {\r\n pulseDash(nextActiveId)\r\n }\r\n }\r\n\r\n const scheduleUpdate = () => {\r\n if (frame) return\r\n frame = window.requestAnimationFrame(updateActiveSection)\r\n }\r\n\r\n const scrollParents = new Set([window])\r\n\r\n for (const section of sections) {\r\n const element = getSectionElement(section.id)\r\n if (element) scrollParents.add(getScrollParent(element))\r\n }\r\n\r\n updateActiveSection()\r\n\r\n for (const parent of scrollParents) {\r\n parent.addEventListener(\"scroll\", scheduleUpdate, { passive: true })\r\n }\r\n\r\n window.addEventListener(\"resize\", scheduleUpdate)\r\n\r\n return () => {\r\n if (frame) window.cancelAnimationFrame(frame)\r\n\r\n for (const parent of scrollParents) {\r\n parent.removeEventListener(\"scroll\", scheduleUpdate)\r\n }\r\n\r\n window.removeEventListener(\"resize\", scheduleUpdate)\r\n }\r\n }, [activeOffset, pulseDash, sectionIds, sections])\r\n\r\n return (\r\n \r\n {\r\n clearPendingReset()\r\n pointerInside.current = true\r\n mouseY.set(event.clientY)\r\n }}\r\n onPointerLeave={() => {\r\n pointerInside.current = false\r\n mouseY.set(Infinity)\r\n }}\r\n >\r\n {sections.map((section) => (\r\n \r\n ))}\r\n \r\n \r\n )\r\n}\r\n\r\nexport default ProximitySidebar\r\n", "type": "registry:ui" } ], "type": "registry:ui" }