diff --git a/apps/site/components/withNavBar.tsx b/apps/site/components/withNavBar.tsx
index 4b370fe48fd9b..06285712a5ba6 100644
--- a/apps/site/components/withNavBar.tsx
+++ b/apps/site/components/withNavBar.tsx
@@ -8,6 +8,7 @@ import NavBar from '@node-core/ui-components/Containers/NavBar';
import styles from '@node-core/ui-components/Containers/NavBar/index.module.css';
import GitHubIcon from '@node-core/ui-components/Icons/Social/GitHub';
import { availableLocales } from '@node-core/website-i18n';
+import classNames from 'classnames';
import dynamic from 'next/dynamic';
import { useLocale, useTranslations } from 'next-intl';
import { useTheme } from 'next-themes';
@@ -16,6 +17,7 @@ import SearchButton from '#site/components/Common/Searchbox';
import Link from '#site/components/Link';
import WithBanner from '#site/components/withBanner';
import WithNodejsLogo from '#site/components/withNodejsLogo';
+import { useScrollDirection } from '#site/hooks/client';
import { useSiteNavigation } from '#site/hooks/generic';
import { useRouter, usePathname } from '#site/navigation.mjs';
@@ -42,11 +44,17 @@ const WithNavBar: FC = () => {
const locale = useLocale();
+ const scrollDirection = useScrollDirection();
+
const changeLanguage = (locale: SimpleLocaleConfig) =>
replace(pathname!, { locale: locale.code });
return (
-
+
{t('components.common.skipToContent')}
diff --git a/apps/site/hooks/client/__tests__/useScrollDirection.test.mjs b/apps/site/hooks/client/__tests__/useScrollDirection.test.mjs
new file mode 100644
index 0000000000000..94b9d016a8086
--- /dev/null
+++ b/apps/site/hooks/client/__tests__/useScrollDirection.test.mjs
@@ -0,0 +1,95 @@
+import assert from 'node:assert/strict';
+import { afterEach, beforeEach, describe, it } from 'node:test';
+
+import { renderHook, act } from '@testing-library/react';
+
+import useScrollDirection from '#site/hooks/client/useScrollDirection.js';
+
+describe('useScrollDirection', () => {
+ let scrollY;
+ let originalRAF;
+
+ beforeEach(() => {
+ scrollY = 0;
+
+ Object.defineProperty(window, 'scrollY', {
+ get: () => scrollY,
+ configurable: true,
+ });
+
+ originalRAF = window.requestAnimationFrame;
+ Object.defineProperty(window, 'requestAnimationFrame', {
+ value: cb => {
+ cb();
+ return 1;
+ },
+ writable: true,
+ configurable: true,
+ });
+ });
+
+ afterEach(() => {
+ window.requestAnimationFrame = originalRAF;
+ });
+
+ it('should return null initially (at top of page)', () => {
+ const { result } = renderHook(() => useScrollDirection());
+ assert.equal(result.current, null);
+ });
+
+ it('should return "down" when scrolling down past threshold', () => {
+ const { result } = renderHook(() => useScrollDirection());
+
+ act(() => {
+ scrollY = 100;
+ window.dispatchEvent(new Event('scroll'));
+ });
+
+ assert.equal(result.current, 'down');
+ });
+
+ it('should return "up" when scrolling up past threshold', () => {
+ const { result } = renderHook(() => useScrollDirection());
+
+ act(() => {
+ scrollY = 100;
+ window.dispatchEvent(new Event('scroll'));
+ });
+
+ act(() => {
+ scrollY = 50;
+ window.dispatchEvent(new Event('scroll'));
+ });
+
+ assert.equal(result.current, 'up');
+ });
+
+ it('should not change direction for scroll less than threshold', () => {
+ const { result } = renderHook(() => useScrollDirection());
+
+ act(() => {
+ scrollY = 5;
+ window.dispatchEvent(new Event('scroll'));
+ });
+
+ assert.equal(result.current, null);
+ });
+
+ it('should return null when scrolling back to top', () => {
+ const { result } = renderHook(() => useScrollDirection());
+
+ act(() => {
+ scrollY = 100;
+ window.dispatchEvent(new Event('scroll'));
+ });
+
+ assert.equal(result.current, 'down');
+
+ act(() => {
+ scrollY = 0;
+ window.dispatchEvent(new Event('scroll'));
+ });
+
+ assert.equal(result.current, null);
+ });
+});
diff --git a/apps/site/hooks/client/index.ts b/apps/site/hooks/client/index.ts
index bd399cf4e8b4a..3d533ed878ee2 100644
--- a/apps/site/hooks/client/index.ts
+++ b/apps/site/hooks/client/index.ts
@@ -3,3 +3,4 @@ export { default as useMediaQuery } from './useMediaQuery';
export { default as useClientContext } from './useClientContext';
export { default as useScrollToElement } from './useScrollToElement';
export { default as useScroll } from './useScroll';
+export { default as useScrollDirection } from './useScrollDirection';
diff --git a/apps/site/hooks/client/useScrollDirection.ts b/apps/site/hooks/client/useScrollDirection.ts
new file mode 100644
index 0000000000000..a88aca4aadc17
--- /dev/null
+++ b/apps/site/hooks/client/useScrollDirection.ts
@@ -0,0 +1,52 @@
+'use client';
+
+import { useState, useEffect, useRef } from 'react';
+
+type ScrollDirection = 'up' | 'down' | null;
+
+const SCROLL_THRESHOLD = 10;
+
+const useScrollDirection = (): ScrollDirection => {
+ const [scrollDirection, setScrollDirection] = useState(null);
+ const lastScrollY = useRef(0);
+ const ticking = useRef(false);
+
+ useEffect(() => {
+ const updateScrollDirection = () => {
+ const currentScrollY = window.scrollY;
+
+ if (currentScrollY <= 0) {
+ setScrollDirection(null);
+ lastScrollY.current = currentScrollY;
+ ticking.current = false;
+ return;
+ }
+
+ const diff = Math.abs(currentScrollY - lastScrollY.current);
+
+ if (diff < SCROLL_THRESHOLD) {
+ ticking.current = false;
+ return;
+ }
+
+ setScrollDirection(currentScrollY > lastScrollY.current ? 'down' : 'up');
+ lastScrollY.current = currentScrollY;
+ ticking.current = false;
+ };
+
+ const onScroll = () => {
+ if (!ticking.current) {
+ ticking.current = true;
+ window.requestAnimationFrame(updateScrollDirection);
+ }
+ };
+
+ window.addEventListener('scroll', onScroll, { passive: true });
+
+ return () => window.removeEventListener('scroll', onScroll);
+ }, []);
+
+ return scrollDirection;
+};
+
+export default useScrollDirection;
diff --git a/apps/site/hooks/server/index.ts b/apps/site/hooks/server/index.ts
index 4493382a557e3..5e4915d6de740 100644
--- a/apps/site/hooks/server/index.ts
+++ b/apps/site/hooks/server/index.ts
@@ -1,3 +1,4 @@
export { default as useClientContext } from './useClientContext';
export { default as useScrollToElement } from './useScrollToElement';
export { default as useScroll } from './useScroll';
+export { default as useScrollDirection } from './useScrollDirection';
diff --git a/apps/site/hooks/server/useScrollDirection.ts b/apps/site/hooks/server/useScrollDirection.ts
new file mode 100644
index 0000000000000..105a0159323af
--- /dev/null
+++ b/apps/site/hooks/server/useScrollDirection.ts
@@ -0,0 +1,5 @@
+const useScrollDirection = () => {
+ throw new Error('Attempted to call useScrollDirection from RSC');
+};
+
+export default useScrollDirection;
diff --git a/packages/ui-components/src/Containers/NavBar/index.module.css b/packages/ui-components/src/Containers/NavBar/index.module.css
index a7f3a189d5cc3..6cb865c73ff8f 100644
--- a/packages/ui-components/src/Containers/NavBar/index.module.css
+++ b/packages/ui-components/src/Containers/NavBar/index.module.css
@@ -135,3 +135,16 @@
}
}
}
+
+.navBarWrapper {
+ @apply xl:sticky
+ xl:top-0
+ xl:z-50
+ xl:transition-transform
+ xl:duration-300
+ xl:ease-in-out;
+}
+
+.navBarWrapper.hidden {
+ @apply xl:-translate-y-full;
+}