Panel History
Makes the browser Back button and the Android back gesture close the panel on top instead of leaving the page. One shared stack and one listener for every Drawer, Sheet, Dialog and Lightbox on screen.
Default
Pass the panel's open ref. Opening pushes a history entry, closing rewinds it, and a back press closes the panel rather than navigating. Open the panel below and press your browser's Back button.
Usage
The whole API is one call in setup. There is nothing to unregister: the scope teardown removes the entry, which is what covers panels that are destroyed rather than closed.
<script setup>
import { ref } from "vue";
import { usePanelHistory } from "@/components/ui/panel-history";
const open = ref(false);
usePanelHistory(open);
</script>Already wired
These call it themselves, so a page that uses them needs no code at all. Reach for the composable only when you build a dismissible surface that is none of them.
- Drawer and Sheet
- Dialog
- Lightbox
- The app header menu
Why it is a stack
Nested panels each push their own entry, so a back press has to take exactly one level down, the same way Escape does. A panel that listened on its own would close on any popstate, including the history.back() its child fires while closing, and one tap on Back inside a nested panel would take the whole stack with it. So there is a single listener, it only ever closes the panel on top, and the rewind a panel performs on its own way out is marked so it does not read as a back gesture for the one behind it.
Leftover entries
When a click both closes panels and navigates, the rewind stands down and each panel leaves one entry behind. Those entries carry the URL of the page below, so left alone they read as back presses that do nothing. The listener recognises them, once the stack is empty, and skips them silently. The empty-stack test is what makes that safe: the same shape with a non-empty stack is a nested child rewinding onto its live parent's entry, which must not be touched.
Implementation notes
- A rewind the module asked for stays claimable for one second rather than being counted. history.back() is applied asynchronously and is not guaranteed to produce a popstate at all, and a plain counter leaks: one undelivered rewind and every real back press afterwards is swallowed until a full reload.
- The pushed state spreads the existing history.state rather than replacing it, because vue-router keeps its own bookkeeping there and an entry stripped of it loses scroll restoration.
- The popstate listener is installed when the first panel opens and never removed. An entry can outlive every panel, so one passive listener for the life of the app is cheaper than proving none is left.
- The skip loop is bounded at ten, purely so a browser that answers history.back() with a popstate that does not move can never spin.
API Reference
usePanelHistory(isOpen)
Accessibility
Keyboard shortcuts and ARIA behavior.
- This exists because on Android the back gesture is how people expect to dismiss a sheet, and without it they leave the page instead. Neither Base UI nor coss ship anything like it.
- Server-safe: the click hook checks for window, so it is a no-op during SSR.