diff --git a/packages/gitbook/src/components/RootLayout/RootLayoutClientContexts.tsx b/packages/gitbook/src/components/RootLayout/RootLayoutClientContexts.tsx
index 68a95dde55..1ac89b8176 100644
--- a/packages/gitbook/src/components/RootLayout/RootLayoutClientContexts.tsx
+++ b/packages/gitbook/src/components/RootLayout/RootLayoutClientContexts.tsx
@@ -5,7 +5,7 @@ import type React from 'react';
import { TranslateContext } from '@/intl/client';
import type { TranslationLanguage } from '@/intl/translations';
import { TooltipProvider } from '@radix-ui/react-tooltip';
-import { HashProvider } from '../hooks';
+import { NavigationStatusProvider } from '../hooks';
import { LoadingStateProvider } from '../primitives/LoadingStateProvider';
/**
@@ -20,9 +20,9 @@ export function RootLayoutClientContexts(props: {
return (
-
+
{children}
-
+
);
diff --git a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx
index a9be339da8..f1376f1a7b 100644
--- a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx
+++ b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx
@@ -25,6 +25,7 @@ import { InsightsProvider, VisitorSessionProvider } from '../Insights';
import { SearchContainer } from '../Search';
import { SiteSectionList, encodeClientSiteSections } from '../SiteSections';
import { CurrentContentProvider } from '../hooks';
+import { NavigationLoader } from '../primitives/NavigationLoader';
import { SpaceLayoutContextProvider } from './SpaceLayoutContext';
type SpaceLayoutProps = {
@@ -125,6 +126,7 @@ export function SpaceLayout(props: SpaceLayoutProps) {
+
{customization.ai?.mode === CustomizationAIMode.Assistant ? (
) : null}
diff --git a/packages/gitbook/src/components/hooks/useHash.tsx b/packages/gitbook/src/components/hooks/useHash.tsx
index efcb8249a2..1c3957ab2e 100644
--- a/packages/gitbook/src/components/hooks/useHash.tsx
+++ b/packages/gitbook/src/components/hooks/useHash.tsx
@@ -1,17 +1,26 @@
'use client';
+
+import { usePathname } from 'next/navigation';
import React from 'react';
-export const HashContext = React.createContext<{
+export const NavigationStatusContext = React.createContext<{
hash: string | null;
/**
- * Updates the hash value from the URL provided here.
- * It will then be used by the `useHash` hook.
+ * Updates the navigation state from the URL provided here.
* URL can be relative or absolute.
*/
- updateHashFromUrl: (href: string) => void;
+ onNavigationClick: (href: string) => void;
+ /**
+ * Indicates if a link has been clicked recently.
+ * Becomes true after a click and resets to false when pathname changes.
+ * It is debounced to avoid flickering on fast navigations.
+ * Debounce time is 400ms (= doherty threshold for responsiveness).
+ */
+ isNavigating: boolean;
}>({
hash: null,
- updateHashFromUrl: () => {},
+ onNavigationClick: () => {},
+ isNavigating: false,
});
function getHash(): string | null {
@@ -21,20 +30,62 @@ function getHash(): string | null {
return window.location.hash.slice(1);
}
-export const HashProvider: React.FC> = ({ children }) => {
+export const NavigationStatusProvider: React.FC = ({ children }) => {
const [hash, setHash] = React.useState(getHash);
- const updateHashFromUrl = React.useCallback((href: string) => {
+ const [isNavigating, setIsNavigating] = React.useState(false);
+ const timeoutRef = React.useRef(null);
+ const pathname = usePathname();
+ const pathnameRef = React.useRef(pathname);
+
+ // Reset isNavigating when pathname changes
+ React.useEffect(() => {
+ if (pathnameRef.current !== pathname) {
+ setIsNavigating(false);
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ timeoutRef.current = null;
+ }
+ pathnameRef.current = pathname;
+ }
+ }, [pathname]);
+
+ // Cleanup timeout on unmount
+ React.useEffect(() => {
+ return () => {
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ }
+ };
+ }, []);
+
+ const onNavigationClick = React.useCallback((href: string) => {
const url = new URL(
href,
typeof window !== 'undefined' ? window.location.origin : 'http://localhost'
);
setHash(url.hash.slice(1));
+
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ }
+ if (pathnameRef.current !== url.pathname) {
+ timeoutRef.current = window.setTimeout(() => {
+ setIsNavigating(true);
+ timeoutRef.current = null;
+ return;
+ }, 400); // 400ms timeout - doherty threshold for responsiveness
+ }
}, []);
+
const memoizedValue = React.useMemo(
- () => ({ hash, updateHashFromUrl }),
- [hash, updateHashFromUrl]
+ () => ({ hash, onNavigationClick, isNavigating }),
+ [hash, onNavigationClick, isNavigating]
+ );
+ return (
+
+ {children}
+
);
- return {children};
};
/**
@@ -45,8 +96,16 @@ export const HashProvider: React.FC> = ({ children }
* Since we have a single Link component that handles all links, we can use a context to share the hash.
*/
export function useHash() {
- // const params = useParams();
- const { hash } = React.useContext(HashContext);
+ const { hash } = React.useContext(NavigationStatusContext);
return hash;
}
+
+/**
+ * Hook to get the current navigation state.
+ * @returns True if a navigation has been triggered recently. False otherwise, it also resets to false when the navigation is complete.
+ */
+export function useIsNavigating() {
+ const { isNavigating: hasBeenClicked } = React.useContext(NavigationStatusContext);
+ return hasBeenClicked;
+}
diff --git a/packages/gitbook/src/components/primitives/Link.tsx b/packages/gitbook/src/components/primitives/Link.tsx
index c09180173b..722b7fdb80 100644
--- a/packages/gitbook/src/components/primitives/Link.tsx
+++ b/packages/gitbook/src/components/primitives/Link.tsx
@@ -6,7 +6,7 @@ import React from 'react';
import { tcls } from '@/lib/tailwind';
import { SiteExternalLinksTarget } from '@gitbook/api';
import { type TrackEventInput, useTrackEvent } from '../Insights';
-import { HashContext } from '../hooks';
+import { NavigationStatusContext } from '../hooks';
import { isExternalLink } from '../utils/link';
import { type DesignTokenName, useClassnames } from './StyleProvider';
@@ -72,7 +72,7 @@ export const Link = React.forwardRef(function Link(
) {
const { href, prefetch, children, insights, classNames, className, ...domProps } = props;
const { externalLinksTarget } = React.useContext(LinkSettingsContext);
- const { updateHashFromUrl } = React.useContext(HashContext);
+ const { onNavigationClick } = React.useContext(NavigationStatusContext);
const trackEvent = useTrackEvent();
const forwardedClassNames = useClassnames(classNames || []);
const isExternal = isExternalLink(href);
@@ -81,7 +81,7 @@ export const Link = React.forwardRef(function Link(
const onClick = (event: React.MouseEvent) => {
const isExternalWithOrigin = isExternalLink(href, window.location.origin);
if (!isExternal) {
- updateHashFromUrl(href);
+ onNavigationClick(href);
}
if (insights) {
diff --git a/packages/gitbook/src/components/primitives/NavigationLoader.tsx b/packages/gitbook/src/components/primitives/NavigationLoader.tsx
new file mode 100644
index 0000000000..eca15e2fda
--- /dev/null
+++ b/packages/gitbook/src/components/primitives/NavigationLoader.tsx
@@ -0,0 +1,18 @@
+'use client';
+import { tcls } from '@/lib/tailwind';
+import { useIsNavigating } from '../hooks';
+
+export const NavigationLoader = () => {
+ const isNavigating = useIsNavigating();
+
+ return (
+
+ );
+};
diff --git a/packages/gitbook/tailwind.config.ts b/packages/gitbook/tailwind.config.ts
index 87d42fdeaf..c6b075ef20 100644
--- a/packages/gitbook/tailwind.config.ts
+++ b/packages/gitbook/tailwind.config.ts
@@ -329,6 +329,7 @@ const config: Config = {
exitToRight: 'exitToRight 250ms cubic-bezier(0.83, 0, 0.17, 1) both',
heightIn: 'heightIn 200ms ease both',
+ crawl: 'crawl 2s ease-in-out infinite',
},
keyframes: {
bounceSmall: {
@@ -498,6 +499,20 @@ const config: Config = {
from: { height: '0' },
to: { height: 'max-content' },
},
+ crawl: {
+ '0%': {
+ scale: '0 1',
+ translate: '0 0',
+ },
+ '40%': {
+ scale: '1 1',
+ translate: '100% 0',
+ },
+ '100%': {
+ scale: '0 1',
+ translate: '100% 0',
+ },
+ },
},
boxShadow: {
thinbottom: '0px 1px 0px rgba(0, 0, 0, 0.05)',