diff --git a/.changeset/fix-mobile-drawer-offscreen-scroll.md b/.changeset/fix-mobile-drawer-offscreen-scroll.md
new file mode 100644
index 0000000000..da2c6d4128
--- /dev/null
+++ b/.changeset/fix-mobile-drawer-offscreen-scroll.md
@@ -0,0 +1,5 @@
+---
+default: patch
+---
+
+Fixed the mobile nav drawer sliding permanently out of frame after the keyboard or a focus change scrolled the panel track.
diff --git a/src/app/components/page/MobileNavDrawer.test.tsx b/src/app/components/page/MobileNavDrawer.test.tsx
new file mode 100644
index 0000000000..1f4c45167a
--- /dev/null
+++ b/src/app/components/page/MobileNavDrawer.test.tsx
@@ -0,0 +1,51 @@
+import { render, screen } from '@testing-library/react';
+import { beforeAll, describe, expect, it, vi } from 'vitest';
+import { MemoryRouter } from 'react-router-dom';
+import { MobileNavDrawer } from './MobileNavDrawer';
+
+vi.mock('$state/hooks/settings', () => ({
+ useSetting: () => [true, vi.fn<() => void>()],
+}));
+
+vi.mock('./PersistentRoomHost', () => ({
+ PersistentRoomHost: () =>
,
+}));
+
+beforeAll(() => {
+ window.matchMedia =
+ window.matchMedia ??
+ ((query: string) =>
+ ({
+ matches: false,
+ media: query,
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ addListener: () => {},
+ removeListener: () => {},
+ onchange: null,
+ dispatchEvent: () => false,
+ }) as unknown as MediaQueryList);
+});
+
+const renderDrawer = () =>
+ render(
+
+ nav}>
+ content
+
+
+ );
+
+describe('MobileNavDrawer', () => {
+ // The panels sit side by side in a track twice the viewport wide, moved by transform.
+ // `hidden` leaves a scrollport that focus or scrollIntoView scrolls a full panel width,
+ // stacking on top of the transform and stranding the active panel off frame.
+ it('clips the viewport instead of hiding overflow, so it can never be scrolled', () => {
+ renderDrawer();
+
+ const viewport = screen.getByTestId('mobile-nav-drawer-viewport');
+
+ expect(viewport.style.overflow).toBe('clip');
+ expect(viewport.style.overflow).not.toBe('hidden');
+ });
+});
diff --git a/src/app/components/page/MobileNavDrawer.tsx b/src/app/components/page/MobileNavDrawer.tsx
index 3cd0b84a30..ee61d3b6cf 100644
--- a/src/app/components/page/MobileNavDrawer.tsx
+++ b/src/app/components/page/MobileNavDrawer.tsx
@@ -340,6 +340,7 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra
{
const viewport = viewportRef.current;
const touch = event.touches[0];
@@ -419,7 +420,9 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra
flexGrow: 1,
height: '100%',
width: '100%',
- overflow: 'hidden',
+ // `clip`, not `hidden`: hidden keeps a scrollport, so a focus or scrollIntoView on
+ // the revealed panel scrolls it a full panel width out of frame, permanently.
+ overflow: 'clip',
overscrollBehaviorX: 'none',
touchAction: 'pan-y',
}}