Toggle Sidebar B
AppearanceLight & dark mode D

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.

Replay preview

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.

vue
<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)

PropTypeDefaultDescription
isOpenRef<boolean>The panel's open state. Watched, not owned: a back press sets it to false and the panel closes through its normal path.

Accessibility

Keyboard shortcuts and ARIA behavior.

Shortcut Description
EscUnaffected. The panel's own Escape handling closes it, which rewinds the entry the same way a click on the backdrop does.
Alt + The keyboard form of Back. Closes the panel on top instead of navigating, same as the button and the Android gesture.
  • 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.