diff --git a/src/components/header.tsx b/src/components/header.tsx
index c00e48b684d7df..c51b32230da37e 100644
--- a/src/components/header.tsx
+++ b/src/components/header.tsx
@@ -6,6 +6,7 @@ import dynamic from 'next/dynamic';
import Image from 'next/image';
import Link from 'next/link';
import {useCallback, useEffect, useState} from 'react';
+import {useBodyScrollLock} from 'sentry-docs/hooks/useBodyScrollLock';
import SentryLogoSVG from 'sentry-docs/logos/sentry-logo-dark.svg';
import {Platform} from 'sentry-docs/types';
@@ -60,46 +61,70 @@ export function Header({
}, [])
);
- // Close mobile search overlay on navigation
+ // Close mobile overlays on navigation
useEffect(() => {
setMobileSearchOpen(false);
+ setHomeMobileNavOpen(false);
}, [pathname]);
- // Lock body scroll when mobile search overlay is open, close on resize to desktop or escape key
+ const closeSidebar = useCallback(() => {
+ const checkbox = document.getElementById(sidebarToggleId) as HTMLInputElement | null;
+ if (checkbox) {
+ checkbox.checked = false;
+ }
+ setSidebarOpen(false);
+ }, []);
+
+ useBodyScrollLock(mobileSearchOpen);
+ useBodyScrollLock(homeMobileNavOpen);
+
+ // Close the home mobile nav if the viewport is resized to desktop width
useEffect(() => {
- if (mobileSearchOpen) {
- document.body.style.overflow = 'hidden';
+ if (!homeMobileNavOpen) {
+ return undefined;
+ }
+ const handleResize = () => {
+ if (window.innerWidth >= 768) {
+ setHomeMobileNavOpen(false);
+ }
+ };
+ window.addEventListener('resize', handleResize);
+ return () => window.removeEventListener('resize', handleResize);
+ }, [homeMobileNavOpen]);
- // Close mobile search if viewport is resized to desktop width
- const handleResize = () => {
- if (window.innerWidth >= 768) {
- setMobileSearchOpen(false);
- }
- };
+ // Close mobile search on resize to desktop or escape key
+ useEffect(() => {
+ if (!mobileSearchOpen) {
+ return undefined;
+ }
- // Close mobile search on escape key (only if search input is empty)
- const handleKeyDown = (e: KeyboardEvent) => {
- if (e.key === 'Escape') {
- // Check if search input has a value - if so, let the Search component
- // handle the escape key to clear the query first
- const searchInput = document.querySelector(
- '.mobile-search-overlay input[type="text"]'
- ) as HTMLInputElement | null;
- if (!searchInput?.value) {
- setMobileSearchOpen(false);
- }
+ // Close mobile search if viewport is resized to desktop width
+ const handleResize = () => {
+ if (window.innerWidth >= 768) {
+ setMobileSearchOpen(false);
+ }
+ };
+
+ // Close mobile search on escape key (only if search input is empty)
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ // Check if search input has a value - if so, let the Search component
+ // handle the escape key to clear the query first
+ const searchInput = document.querySelector(
+ '.mobile-search-overlay input[type="text"]'
+ ) as HTMLInputElement | null;
+ if (!searchInput?.value) {
+ setMobileSearchOpen(false);
}
- };
+ }
+ };
- window.addEventListener('resize', handleResize);
- window.addEventListener('keydown', handleKeyDown);
- return () => {
- document.body.style.overflow = '';
- window.removeEventListener('resize', handleResize);
- window.removeEventListener('keydown', handleKeyDown);
- };
- }
- return undefined;
+ window.addEventListener('resize', handleResize);
+ window.addEventListener('keydown', handleKeyDown);
+ return () => {
+ window.removeEventListener('resize', handleResize);
+ window.removeEventListener('keydown', handleKeyDown);
+ };
}, [mobileSearchOpen]);
// Track sidebar checkbox state for non-home pages
@@ -126,31 +151,28 @@ export function Header({
return () => checkbox.removeEventListener('change', handleChange);
}, [isHomePage]);
- // Lock body scroll when sidebar is open on mobile (fixes iOS Safari touch scrolling)
+ useBodyScrollLock(sidebarOpen);
+
+ // Close sidebar if viewport is resized to desktop width
useEffect(() => {
- if (sidebarOpen) {
- document.body.style.overflow = 'hidden';
+ if (!sidebarOpen) {
+ return undefined;
+ }
- // Close sidebar if viewport is resized to desktop width
- const handleResize = () => {
- if (window.innerWidth >= 768) {
- const checkbox = document.getElementById(
- sidebarToggleId
- ) as HTMLInputElement | null;
- if (checkbox) {
- checkbox.checked = false;
- setSidebarOpen(false);
- }
+ const handleResize = () => {
+ if (window.innerWidth >= 768) {
+ const checkbox = document.getElementById(
+ sidebarToggleId
+ ) as HTMLInputElement | null;
+ if (checkbox) {
+ checkbox.checked = false;
+ setSidebarOpen(false);
}
- };
+ }
+ };
- window.addEventListener('resize', handleResize);
- return () => {
- document.body.style.overflow = '';
- window.removeEventListener('resize', handleResize);
- };
- }
- return undefined;
+ window.addEventListener('resize', handleResize);
+ return () => window.removeEventListener('resize', handleResize);
}, [sidebarOpen]);
// Show header search if: not on home page, OR on home page but home search is scrolled out of view
@@ -283,14 +305,7 @@ export function Header({
onClick={() => {
setMobileSearchOpen(true);
setHomeMobileNavOpen(false);
- // Close sidebar to prevent competing scroll locks
- const checkbox = document.getElementById(
- sidebarToggleId
- ) as HTMLInputElement | null;
- if (checkbox) {
- checkbox.checked = false;
- setSidebarOpen(false);
- }
+ closeSidebar();
}}
aria-label="Search"
>
@@ -350,7 +365,7 @@ export function Header({
{/* Home page mobile navigation overlay */}
{isHomePage && homeMobileNavOpen && (
diff --git a/src/components/search/index.tsx b/src/components/search/index.tsx
index 2fed908d2d15cf..52cfecf08fbd8a 100644
--- a/src/components/search/index.tsx
+++ b/src/components/search/index.tsx
@@ -60,6 +60,8 @@ const search = new SentryGlobalSearch(config);
type Props = {
autoFocus?: boolean;
+ /** Called before the Kapa modal opens, so a parent overlay can close itself first. */
+ onAskAi?: () => void;
path?: string;
searchPlatforms?: string[];
showChatBot?: boolean;
@@ -83,6 +85,7 @@ const SDK_AGNOSTIC_PATH_PREFIXES = [
export function Search({
path,
autoFocus,
+ onAskAi,
searchPlatforms = [],
useStoredSearchPlatforms = true,
}: Props) {
@@ -373,10 +376,13 @@ export function Search({
className={styles['sgs-ai-button']}
onClick={() => {
if (window.Kapa?.open) {
- // close search results
setInputFocus(false);
- // open kapa modal
- window.Kapa.open({query, submit: true});
+ onAskAi?.();
+ // Open next frame, after the overlay's scroll lock is released
+ // on commit, so Kapa's lock and ours never overlap.
+ requestAnimationFrame(() => {
+ window.Kapa?.open({query, submit: true});
+ });
}
}}
>
diff --git a/src/components/sidebar/MobileSidebarNav.tsx b/src/components/sidebar/MobileSidebarNav.tsx
index 86c9d4bc021cc6..2f3812c1cbe933 100644
--- a/src/components/sidebar/MobileSidebarNav.tsx
+++ b/src/components/sidebar/MobileSidebarNav.tsx
@@ -1,5 +1,6 @@
'use client';
+import {ChevronDownIcon} from '@radix-ui/react-icons';
import Link from 'next/link';
import {usePathname} from 'next/navigation';
import {useEffect, useState} from 'react';
@@ -12,6 +13,11 @@ export function MobileSidebarNav({platforms = []}: {platforms?: Platform[]}) {
const isActive = (href: string) => pathname?.startsWith(href);
+ // Collapse all top-level sections behind a single "Menu" disclosure so the
+ // current section's page nav stays near the top on mobile. Collapsed by
+ // default; the active section is conveyed by the page nav heading below.
+ const [menuOpen, setMenuOpen] = useState(false);
+
// Compute the SDK link href - use stored platform URL if available
const [sdkLinkHref, setSdkLinkHref] = useState('/platforms/');
@@ -61,37 +67,52 @@ export function MobileSidebarNav({platforms = []}: {platforms?: Platform[]}) {
return (
- {/* Main navigation sections - simple links that navigate to index pages */}
-
+
+ {menuOpen && (
+ // Main navigation sections - simple links that navigate to index pages
+
+ )}
);
}
diff --git a/src/components/sidebar/style.module.scss b/src/components/sidebar/style.module.scss
index 48e33b53eea5a5..627da63de22822 100644
--- a/src/components/sidebar/style.module.scss
+++ b/src/components/sidebar/style.module.scss
@@ -68,6 +68,11 @@
@media only screen and (max-width: 767px) {
&:has(> #navbar-menu-toggle:checked) {
display: flex;
+ // On mobile the whole sidebar is a single scroll container, so the
+ // expanded section menu and the page nav scroll together instead of
+ // fighting over the fixed height with a clipped inner scroll region.
+ overflow-y: auto;
+ overscroll-behavior: contain;
& + :global(.main-content) {
margin-left: 0;
@@ -93,6 +98,14 @@
@media only screen and (min-width: 768px) {
display: block;
}
+
+ // On mobile the outer .sidebar is the single scroll container, so let .toc
+ // grow naturally instead of nesting its own scroll region.
+ @media only screen and (max-width: 767px) {
+ flex: 0 0 auto;
+ overflow: visible;
+ min-height: auto;
+ }
}
:global(.toc-item) {
@@ -155,6 +168,13 @@
overflow: auto;
min-height: 0; // Critical for flex children to allow shrinking and enable scrolling
-webkit-overflow-scrolling: touch; // Smooth momentum scrolling on iOS
+
+ // On mobile the outer .sidebar is the single scroll container (see .sidebar).
+ @media only screen and (max-width: 767px) {
+ flex: 0 0 auto;
+ overflow: visible;
+ min-height: auto;
+ }
}
.sidebar-external-links {
@@ -229,7 +249,7 @@
padding: 0.5rem 0 0.375rem;
margin-top: 0.5rem;
list-style: none;
-
+
&:first-child {
margin-top: 0;
}
diff --git a/src/hooks/useBodyScrollLock.tsx b/src/hooks/useBodyScrollLock.tsx
new file mode 100644
index 00000000000000..2ee47205e2c7e7
--- /dev/null
+++ b/src/hooks/useBodyScrollLock.tsx
@@ -0,0 +1,51 @@
+'use client';
+
+import {useEffect, useLayoutEffect} from 'react';
+
+const useIsomorphicLayoutEffect =
+ typeof window !== 'undefined' ? useLayoutEffect : useEffect;
+
+let lockCount = 0;
+
+function lockBodyScroll() {
+ if (lockCount === 0) {
+ // Lock the documentElement (the actual scroll container) as well as body,
+ // and disable overscroll so the page can't rubber-band behind a fixed
+ // overlay at the scroll bounds (which detaches it from the header on iOS).
+ const html = document.documentElement;
+ html.style.overflow = 'hidden';
+ html.style.overscrollBehavior = 'none';
+ document.body.style.overflow = 'hidden';
+ }
+ lockCount += 1;
+}
+
+function unlockBodyScroll() {
+ if (lockCount === 0) {
+ return;
+ }
+ lockCount -= 1;
+ if (lockCount === 0) {
+ // Clear, don't restore a captured value — it may be a third party's
+ // transient lock (e.g. Kapa); re-applying it would strand the page.
+ const html = document.documentElement;
+ html.style.overflow = '';
+ html.style.overscrollBehavior = '';
+ document.body.style.overflow = '';
+ }
+}
+
+/**
+ * Reference-counted body scroll lock; safe to use from several overlays at once.
+ * Runs as a layout effect so the release happens synchronously on commit, before
+ * any rAF that hands the lock off to a third-party modal.
+ */
+export function useBodyScrollLock(active: boolean) {
+ useIsomorphicLayoutEffect(() => {
+ if (!active) {
+ return undefined;
+ }
+ lockBodyScroll();
+ return unlockBodyScroll;
+ }, [active]);
+}