@magic-spells/panel-stack

A nested-panel stack web component with fluid push/pop transitions β€” drill-down menus, settings flows, multi-step wizards.


Keywords
panel-stack, navigation, drill-down, nested-menu, wizard, stepper, web-components, custom-elements, mobile-menu
License
MIT
Install
npm install @magic-spells/panel-stack@0.2.0

Documentation

@magic-spells/panel-stack

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
  • inert on hidden panels β€” focus stays where it should
  • All motion tunable via CSS custom properties

Install

npm i @magic-spells/panel-stack
import '@magic-spells/panel-stack';
<link rel="stylesheet" href="https://unpkg.com/@magic-spells/panel-stack/css/min" />

Markup

<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.

API

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;             // 2

Controlled mode β€” the current attribute

current 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.

Panels added or removed at runtime

<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 waiting

A 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.

Events

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();
});

Effects

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.

Rounded corners

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

CSS custom properties

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;
}

Focus

On push() and reset(), focus moves to the new current panel β€” specifically:

  1. The first descendant with data-stack-focus, if one exists
  2. 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.

Keyboard

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().

License

MIT


Made by Cory Schulz