back gesture/button should close drawer instead of navigating away
On mobile, when a drawer is open and the user triggers a back action (hardware/browser back button or swipe-back gesture), the app currently navigates to the previous route. The expected behavior is that the back action should dismiss the drawer first, and
only navigate back if the drawer is already closed , matching native mobile app conventions.
Current behavior:
- Drawer is open
- User presses back / swipes back
- Route changes (drawer is dismissed as a side effect, but the underlying page also changes)
Expected behavior:
- Drawer is open
- User presses back / swipes back
- Drawer closes; route stays the same
- A second back action then navigates away
Proposed approach:
We can leverage a history-aware hook to push a transient history entry when the drawer opens, so the next popstate is consumed by closing the drawer rather than leaving the page.
'use client';
import { useEffect, useId } from 'react';
import { useAsRef } from './use-as-ref';
const DRAWER_ID_KEY = '__drawerId';
type DrawerHistoryState = (Record<string, unknown> & { [DRAWER_ID_KEY]?: string }) | null;
type Entry = { id: string; close: () => void };
const IS_SERVER = typeof window === 'undefined';
const stack: Entry[] = [];
const nativePushState = IS_SERVER ? null : window.history.pushState.bind(window.history);
function currentDrawerId(): string | undefined {
const state = window.history.state as DrawerHistoryState;
return state?.[DRAWER_ID_KEY];
}
if (!IS_SERVER) {
window.addEventListener('popstate', () => {
while (stack.length > 0) {
const top = stack[stack.length - 1];
if (currentDrawerId() === top.id) break;
stack.pop();
top.close();
}
});
}
export function useDrawerHistory(
open: boolean | undefined,
onOpenChange: ((open: boolean) => void) | undefined,
) {
const id = useId();
const onOpenChangeRef = useAsRef(onOpenChange);
useEffect(() => {
if (!open || !nativePushState) return;
const entry: Entry = {
id,
close: () => onOpenChangeRef.current?.(false),
};
stack.push(entry);
// Preserve existing history state so Next.js router state isn't clobbered.
const existing = (window.history.state ?? {}) as Record<string, unknown>;
nativePushState({ ...existing, [DRAWER_ID_KEY]: id }, '');
return () => {
const idx = stack.indexOf(entry);
if (idx === -1) return;
stack.splice(idx, 1);
if (currentDrawerId() === id) {
window.history.back();
}
};
}, [open, id, onOpenChangeRef]);
}
and use like thsi with shadcn ui for example
function Drawer(props: React.ComponentProps<typeof DrawerPrimitive.Root>) {
useDrawerHistory(props.open, props.onOpenChange);
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
}Source: emilkowalski/vaul