A nested-panel stack web component with fluid push/pop transitions. Built for mobile menus, settings drill-downs, multi-step wizards, and anything that wants the feel of a navigation stack.
π Live Demo β See it in action!
- 1.93 KB JS gzip, 0.79 KB CSS gzip, zero dependencies
- Two custom elements:
<panel-stack>+<stack-panel> - Honors
prefers-reduced-motion -
inerton hidden panels β focus stays where it should - All motion tunable via CSS custom properties
npm i @magic-spells/panel-stackimport '@magic-spells/panel-stack';<link rel="stylesheet" href="https://unpkg.com/@magic-spells/panel-stack/css/min" /><panel-stack initial="root">
<stack-panel handle="root">
<button data-action-stack-push target="shop">Shop</button>
<a href="/about">About</a>
</stack-panel>
<stack-panel handle="shop">
<button data-action-stack-pop>Back</button>
<button data-action-stack-push target="shop-women">Women</button>
</stack-panel>
<stack-panel handle="shop-women">
<button data-action-stack-pop>Back</button>
<a href="/dresses">Dresses</a>
</stack-panel>
</panel-stack>The parent of <panel-stack> needs a defined size β panels are position: absolute; inset: 0 and the stack clips them.
const stack = document.querySelector('panel-stack');
stack.push('shop'); // slide to a panel
stack.push('shop', triggerEl); // optional 2nd arg β pop() restores focus to it
stack.pop(); // back one level
stack.reset(); // collapse to root
stack.current = 'shop'; // navigate β same semantics as the `current` attribute
stack.current; // 'shop'
stack.currentHandle; // 'shop' (unchanged alias, kept for back-compat)
stack.currentPanel; // <stack-panel handle="shop">
stack.depth; // 2current is the handle of the panel that should be current. Set it and the stack
navigates itself; the component writes it back after every navigation, whatever
the source (push(), pop(), reset(), a declarative trigger, the Escape key). So
the attribute is always safe to read, and a framework that renders it stays in sync.
<panel-stack current="shop"> β¦ </panel-stack>stack.setAttribute('current', 'shop-women'); // or: stack.current = 'shop-women'What a new value does:
| The new handle is⦠| What happens |
|---|---|
| already current | nothing β no navigation, no event |
| the root panel |
reset() β one panel-stack:reset
|
| a previous panel (in the current ancestry) | pops back to it β one panel-stack:pop per level
|
| any other known panel |
push(handle) β one cancelable panel-stack:push
|
Every one of those (bar the no-op) ends with a panel-stack:change carrying the settled handle. A multi-level pop-back is genuinely one navigation per level β a pop and a change each β and the intermediate panel animates through on its way, the same way reset() has always behaved. push(handle) follows the same rules β pushing a panel already in the ancestry pops back to it rather than duplicating the frame.
| unknown | ignored, and the attribute is restored to the real current handle |
A panel-stack:push that a listener cancels with preventDefault() also restores the
attribute, so an optimistic parent sees its value undone rather than silently diverging.
Removing the attribute doesn't navigate β it's written straight back.
At first connect, an authored current starts the stack on that panel with no
events. The panel named by initial (or the first child) stays underneath it as the
root, so pop() and Escape still go back:
<!-- opens on `shop`, one level deep; Back returns to `root` -->
<panel-stack initial="root" current="shop"> β¦ </panel-stack>Controlled from a parent framework:
// parent β stack
stack.current = state.panel;
// stack β parent (every source, including Escape and back buttons)
stack.addEventListener('panel-stack:change', (e) => setState({ panel: e.detail.handle }));panel-stack:change fires after the stack settles β unlike push and pop, which
fire before it moves. Read stack.current inside a push/pop listener and you get the
old handle; a parent that writes that back on the next render snaps the panel back. Use
panel-stack:change, or e.detail.toHandle / e.detail.rootHandle.
<stack-panel> children are re-indexed when they change β a MutationObserver watches
the child list, and push() also re-scans on demand, so a panel appended one line earlier
is pushable immediately:
stack.appendChild(newPanel); // <stack-panel handle="new">
stack.current = 'new'; // works β no waitingA new panel is parked off-screen (state="next", inert) until something pushes it.
Removing a panel that's in the current ancestry drops its frame from the stack and falls
back to the nearest surviving ancestor β if the current panel itself is removed, that
ancestor becomes current, the attribute reflects it, and focus moves into it. This is a
DOM correction, not a navigation, so no push/pop event fires for it.
All events bubble + composed. panel-stack:push is cancelable. push, pop and reset
fire before the stack mutates; change fires after it settles.
stack.addEventListener('panel-stack:push', (e) => {
console.log(e.detail); // { fromHandle: 'root', toHandle: 'shop' }
});
stack.addEventListener('panel-stack:pop', (e) => { /* { fromHandle, toHandle } */ });
stack.addEventListener('panel-stack:reset', (e) => { /* { rootHandle } */ });
// panel-stack:change fires last, after the stack has settled β one event for
// every navigation, whatever caused it. This is the one to sync state from.
stack.addEventListener('panel-stack:change', (e) => {
console.log(e.detail); // { handle: 'shop' }
});
// Cancel a push:
stack.addEventListener('panel-stack:push', (e) => {
if (e.detail.toHandle === 'admin' && !user.isAdmin) e.preventDefault();
});Two visual styles for how state="previous" panels look. Pick one with the effect attribute:
<panel-stack effect="slide"> β¦ </panel-stack> <!-- default: slides off to the left -->
<panel-stack effect="stack"> β¦ </panel-stack> <!-- shrinks + blurs + dims behind current -->effect="stack" keeps the previous panel in place while it scales to 0.95, blurs 1px, dims to brightness(0.5), and drops to z-index: -1 behind the current panel. Pop, and it pops back to full size, sharp, and bright.
Both effects share the same per-state CSS variables, so you can fine-tune either one.
<panel-stack> and <stack-panel> use border-radius: inherit. Wrap the stack in a container with a radius and the panels pick it up automatically:
<div style="border-radius: 22px; overflow: hidden; width: 320px; height: 540px;">
<panel-stack effect="stack"> β¦ </panel-stack>
</div>Global timing and perspective:
| Property | Default | Description |
|---|---|---|
--ps-transition-duration |
420ms |
Animation duration (transform + filter) |
--ps-transition-timing |
cubic-bezier(0.16, 0.87, 0.64, 1) |
Easing |
--ps-perspective |
1200px |
Depth of the 3D scene |
Per-state position and filter values. Each state has its own translate, scale, blur, opacity, brightness, and z-index β override one state without affecting the others:
| Property | Default (current Β· previous Β· next) |
|---|---|
--ps-translate-{state} |
0% Β· calc(-100% - 50px) Β· calc(100% + 50px)
|
--ps-scale-x-{state} |
1 Β· 1.1 Β· 1.1
|
--ps-scale-y-{state} |
1 Β· 1 Β· 1
|
--ps-blur-{state} |
0px Β· 2px Β· 2px
|
--ps-opacity-{state} |
1 Β· 0.1 Β· 0.1
|
--ps-z-index-{state} |
1 Β· 0 Β· 2
|
--ps-brightness-previous (default 1) is the only brightness knob β effect="stack" uses it to darken the receding panel.
--ps-scale-x-{state} and --ps-scale-y-{state} accept any number β values < 1 shrink the panel, values > 1 stretch it, negative values flip it (mirror).
effect="stack" overrides the previous defaults to: translate 0%, scale 0.95, blur 1px, opacity 1, brightness 0.5, z-index -1. It also sets --ps-opacity-next: 1 so panels coming in from the right aren't faded during the swap.
Example β make the slide flat (no scale, no blur):
panel-stack {
--ps-scale-x-previous: 1;
--ps-scale-x-next: 1;
--ps-blur-previous: 0px;
--ps-blur-next: 0px;
}On push() and reset(), focus moves to the new current panel β specifically:
- The first descendant with
data-stack-focus, if one exists - Otherwise the first focusable child (button, link, input, etc.)
On pop(), focus is restored to the element that originally pushed the panel you're leaving β matching native back-button behavior. Declarative data-action-stack-push triggers are remembered automatically; for programmatic pushes pass the trigger as the second arg: stack.push('shop', triggerEl). If the trigger has been removed from the DOM, pop falls back to the destination panel's first focusable.
Inactive panels get inert so they can't trap tab navigation or screen reader focus.
Pressing Escape while focus is inside the stack pops one level β but only when there's somewhere to go back to. At the root panel, Esc bubbles untouched so a wrapping <dialog> closes as normal:
<dialog>
<panel-stack>
<stack-panel handle="root">β¦</stack-panel>
<stack-panel handle="settings">β¦</stack-panel>
</panel-stack>
</dialog>Drill into settings, hit Esc β goes back to root, dialog stays open. Hit Esc again β dialog closes. No coordination needed between the two.
If a focused input already consumed Esc (e.g., <input type="search"> clearing its value), the stack stays put. Wizards with unsaved work should confirm or block before calling pop().
MIT
Made by Cory Schulz