diff --git a/docs/app/global.css b/docs/app/global.css index c7ea304b..080f7fad 100644 --- a/docs/app/global.css +++ b/docs/app/global.css @@ -7,6 +7,8 @@ :root { --fd-nav-height: 56px; + --fd-banner-height: 0px; + --fd-tocnav-height: 0px; --background: oklch(1 0 0); @@ -255,15 +257,19 @@ } html { - scroll-behavior: smooth; + scroll-behavior: auto; scroll-padding-top: calc( - var(--fd-nav-height) + + var(--fd-nav-height, 56px) + var(--fd-banner-height, 0px) + var(--fd-tocnav-height, 0px) + - 16px + 24px ); } +html:not([data-anchor-scrolling]) { + scroll-behavior: smooth; +} + /* Global, accessible custom scrollbars */ * { scrollbar-width: thin; /* Firefox */ diff --git a/docs/app/layout.tsx b/docs/app/layout.tsx index 8c7e699f..2bc730e5 100644 --- a/docs/app/layout.tsx +++ b/docs/app/layout.tsx @@ -10,6 +10,7 @@ import { Analytics } from "@vercel/analytics/react"; import { ThemeProvider } from "@/components/theme-provider"; import { Toaster } from "@/components/ui/sonner"; import { CustomSearchDialog } from "@/components/search-dialog"; +import { AnchorScroll } from "@/components/anchor-scroll-fix"; export const metadata = createMetadata({ title: { @@ -58,6 +59,7 @@ export default function Layout({ children }: { children: ReactNode }) { : undefined, }} > + {children} diff --git a/docs/components/anchor-scroll-fix.tsx b/docs/components/anchor-scroll-fix.tsx new file mode 100644 index 00000000..df4e5289 --- /dev/null +++ b/docs/components/anchor-scroll-fix.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +export function AnchorScroll() { + const scrollTimeoutRef = useRef(undefined); + const isScrollingRef = useRef(false); + + useEffect(() => { + function calculateScrollOffset() { + const root = document.documentElement; + const navHeight = parseInt( + getComputedStyle(root).getPropertyValue("--fd-nav-height") || "56", + ); + const bannerHeight = parseInt( + getComputedStyle(root).getPropertyValue("--fd-banner-height") || "0", + ); + const tocnavHeight = parseInt( + getComputedStyle(root).getPropertyValue("--fd-tocnav-height") || "0", + ); + + return navHeight + bannerHeight + tocnavHeight + 24; + } + + function smoothScrollToElement(element: HTMLElement) { + if (isScrollingRef.current) return; + + isScrollingRef.current = true; + document.documentElement.setAttribute("data-anchor-scrolling", "true"); + + const elementRect = element.getBoundingClientRect(); + const scrollOffset = calculateScrollOffset(); + const targetPosition = + window.pageYOffset + elementRect.top - scrollOffset; + + // Simple smooth scroll animation + const startPosition = window.pageYOffset; + const distance = targetPosition - startPosition; + const duration = Math.min(500, Math.abs(distance) * 0.3); + const startTime = performance.now(); + + function animateScroll(currentTime: number) { + const elapsed = currentTime - startTime; + const progress = Math.min(elapsed / duration, 1); + const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3); + const currentPosition = + startPosition + distance * easeOutCubic(progress); + + window.scrollTo(0, currentPosition); + + if (progress < 1) { + requestAnimationFrame(animateScroll); + } else { + document.documentElement.removeAttribute("data-anchor-scrolling"); + isScrollingRef.current = false; + } + } + + requestAnimationFrame(animateScroll); + } + + function handleAnchorScroll() { + if (window.location.hash) { + const element = document.getElementById(window.location.hash.slice(1)); + if (element) { + scrollTimeoutRef.current = setTimeout( + () => smoothScrollToElement(element), + 100, + ); + } + } + } + + function handleHashChange() { + const element = document.getElementById(window.location.hash.slice(1)); + if (element) smoothScrollToElement(element); + } + + function handleAnchorClick(event: Event) { + const link = (event.target as HTMLElement).closest( + 'a[href^="#"]', + ) as HTMLAnchorElement; + + if (link?.hash) { + event.preventDefault(); + const element = document.getElementById(link.hash.slice(1)); + + if (element) { + history.pushState(null, "", link.hash); + smoothScrollToElement(element); + } + } + } + + handleAnchorScroll(); + window.addEventListener("hashchange", handleHashChange); + document.addEventListener("click", handleAnchorClick); + + return () => { + if (scrollTimeoutRef.current) clearTimeout(scrollTimeoutRef.current); + window.removeEventListener("hashchange", handleHashChange); + document.removeEventListener("click", handleAnchorClick); + }; + }, []); + + return null; +} diff --git a/docs/components/docs/layout/nav.tsx b/docs/components/docs/layout/nav.tsx index 23ad1880..43aa49f2 100644 --- a/docs/components/docs/layout/nav.tsx +++ b/docs/components/docs/layout/nav.tsx @@ -48,11 +48,14 @@ export function NavProvider({ if (transparentMode !== "top") return; const listener = () => { + if (document.documentElement.hasAttribute("data-anchor-scrolling")) { + return; + } setTransparent(window.scrollY < 10); }; listener(); - window.addEventListener("scroll", listener); + window.addEventListener("scroll", listener, { passive: true }); return () => { window.removeEventListener("scroll", listener); };