Panel configuration
Options via constructor, data attributes, or both
Setting options
JavaScript options
Panel.init({ axis: 'horizontal', closeOnResize: true });
const panel = new Panel('#my-panel', {
axis: 'vertical',
transitions: true,
autoFocus: 'first',
returnFocus: true
});
Data attributes
<div id="my-panel" data-panel data-panel-axis="horizontal" data-panel-close-on-resize data-panel-transitions="false">
<div class="panel-wrapper">...</div>
</div>
When the same option is set more than one way, the most specific wins: defaults -> JS options -> group attribute -> element attribute. So a data attribute on one panel always beats a global init() option.
That is how one panel opts out of a flag the rest of the page opted into. With Panel.init({ persist: true }) covering every panel, a single panel can still switch it off:
<!-- Persists (inherits the global init option)-->
<div id="saved" data-panel>
<div class="panel-wrapper">...</div>
</div>
<!-- Opts out, even though init() set persist: true-->
<div id="not-saved" data-panel data-panel-persist="false">
<div class="panel-wrapper">...</div>
</div>
For persist and deepLink, a data-panel-group can set the option for every panel inside it at once. A panel’s own attribute still wins over the group’s.
On <ps-panel> (a web component) the same options are plain attributes: no data- prefix, no JS call (third card of each option below). There is no web-component equivalent of a global config object, because connectedCallback fires per element on its own, with no shared init moment. So for page-wide defaults across many elements, JS init() is the better tool. A web component can easily coexist with the regular JS version. Full examples on the Web Component integration page.
Available options
Every option below shows three ways to set it: a JS key, a data attribute, and a plain attribute for <ps-panel>. All three land on the same config, so pick whichever suits how you are authoring.
Where the content sits inside the clipped container while it animates. Handy for panels that reveal from the centre or the far edge.
Default: 'start'
JavaScript
{ align: 'start' }: content grows from the top / left edge{ align: 'center' }: content stays centered{ align: 'end' }: content grows from the bottom / right edge
Data attribute
data-panel-align="start"data-panel-align="center"data-panel-align="end"
Web Component attribute
panel-align="start"panel-align="center"panel-align="end"
Move focus into the panel when it opens, so a screen-reader user knows the content has changed.
false: no focus movementtrue: focus the panel element itself'heading': focus the first h1-h6'first': focus the first focusable element (keyboard events only)'input': focus the first input/select/textarea (bypasses keyboard check)
Pass the original event to open() or toggle() and Panel tells keyboard from mouse: a mouse click skips autofocus, which keeps the page from jumping.
Default: false
JavaScript
{ autoFocus: false }{ autoFocus: true }{ autoFocus: 'heading' }{ autoFocus: 'first' }{ autoFocus: 'input' }
Data attribute
data-panel-auto-focus="true"data-panel-auto-focus="heading"data-panel-auto-focus="first"data-panel-auto-focus="input"
Web Component attribute
panel-auto-focus="true"panel-auto-focus="heading"panel-auto-focus="first"panel-auto-focus="input"
Which dimension animates: vertical grows the height, horizontal grows the width.
Default: 'vertical'
JavaScript
{ axis: 'vertical' }: show-more, accordions{ axis: 'horizontal' }: sidebars, drawers
Data attribute
data-panel-axis="vertical"data-panel-axis="horizontal"
Web Component attribute
panel-axis="vertical"panel-axis="horizontal"
Close the panel when the window resizes. Useful for horizontal panels, whose surrounding layout can break at other viewport widths.
Default: false
JavaScript
{ closeOnResize: true }
Data attribute
data-panel-close-on-resize
Web Component attribute
panel-close-on-resize
When this panel opens, close any other open panel in the same [data-panel-group]. You can also set it on the group element with data-panel-close-siblings.
Default: false
JavaScript
{ closeSiblings: true }
Data attribute
data-panel-close-siblings
Web Component attribute
panel-close-siblings
Note: when this is on, a closing sibling animates with the --ps-open-speed and --ps-open-timing variables, not its own close timing. That keeps the two movements in step instead of looking wobbly.
Log open and close events to the console. Handy when something is opening that shouldn’t be, quiet otherwise.
Default: false
JavaScript
{ debug: true }
Data attribute
data-debug
Web Component attribute
debug
Keep the ?panel= URL param in sync as the panel opens and closes (via history.replaceState, so no new history entries pile up). The address bar then always reflects the current state, ready to copy and share. Reading the param on load happens regardless: deepLink only decides whether opening and closing write back to it. Want shareable URLs and localStorage restore? Turn on persist and deepLink together.
Default: false
JavaScript
{ deepLink: true }
Data attribute
data-panel-deeplink
Web Component attribute
panel-deeplink
The panel needs an id, or there is nothing to put in the URL.
Leave it on. With it off, any open or close that arrives mid-animation or mid-load is dropped, and the one already running finishes first.
Default: true
JavaScript
{ interruptible: false }
Data attribute
data-panel-interruptible="false"
Web Component attribute
panel-interruptible="false"
How long to wait before showing the spinner while async content loads. It prevents spinner flash on fast connections.
Default: 320
JavaScript
{ loadingDelay: 320 }
Data attribute
data-panel-loading-delay="320"
Web Component attribute
panel-loading-delay="320"
The height, in pixels, the panel holds while async content loads. Somewhere for the spinner to live until the real content turns up.
Default: 150
JavaScript
{ loadingHeight: 200 }
Data attribute
data-panel-loading-height="200"
Web Component attribute
panel-loading-height="200"
Save the panel’s open or closed state to localStorage on every change, and restore it on the next load, so the panel comes back the way the user left it.
You can also set it on a [data-panel-group] to cover every direct panel in the group, instead of marking each one.
Default: false
JavaScript
{ persist: true }
Data attribute
data-panel-persist: on the panel elementdata-panel-persist: on a[data-panel-group] element
Web Component attribute
panel-persist
The panel needs an id, or there is nothing to key the storage on.
When the panel closes, send focus back to the trigger that opened it, so a keyboard user picks up where they were.
Default: true
JavaScript
{ returnFocus: true }{ returnFocus: false }
Data attribute
data-panel-return-focus="false"
Web Component attribute
panel-return-focus="false"
Make the panel plain expanded content instead of a collapsible disclosure. A Panel is collapsible, so you only ever set this to say otherwise.
The useful value is a media query: the panel is then a disclosure only while that query does not match, and plain content while it does. That is a responsive navigation in one attribute: a drawer on mobile, an ordinary sidebar on desktop.
<div id="main-nav" data-panel data-panel-static="(min-width: 769px)">
<div class="panel-wrapper">...</div>
</div>
While static the panel is expanded and reachable: no inert, no open or close, and its trigger stops claiming to expand anything (aria-expanded is removed). open(), close() and toggle() do nothing, persist and deepLink do not write, and closeSiblings cannot close it. Crossing the breakpoint never animates. Becoming collapsible again lands the panel closed, because a drawer that reappears already open over your content is never what you want.
The library adds data-ps-static to the panel and to its triggers while static. A trigger for a static panel cannot do anything, so the stylesheet hides it for you: no menu button on desktop, and no breakpoint repeated in your CSS. To keep the button visible and repurpose it, repeat the attribute (the rule that hides it does the same, so that a plain .sidebar button rule in your own CSS cannot out-rank it by accident):
button[data-ps-static][data-ps-static] {
display: revert;
}
Until the JavaScript runs, the panel is closed. A Panel is closed in CSS to begin with, and it is the library that opens it. So on a slow connection there is a short moment where a static panel is not open yet, and if the JavaScript never arrives it stays shut for good. For an accordion that is fine, it is just a closed accordion. For your main navigation it is not: the menu is simply missing.
If the panel holds something that has to be there whatever happens, open it yourself in CSS, for the same screens, so the browser can paint it before the library has even loaded:
@media (min-width: 769px) {
/* Your panel, before the library has marked it static. */
[data-panel-static]:not([data-ps-static]) {
height: auto;
width: auto;
overflow: visible;
}
[data-panel-static]:not([data-ps-static]) > .panel-wrapper {
opacity: 1;
transform: none;
pointer-events: auto;
}
}
Use the same media query you wrote in data-panel-static. As soon as the library loads it adds data-ps-static, your rule stops matching, and the library’s rule takes over with the very same values, so nothing moves.
For a breakpoint the library cannot see (a container query, your own breakpoint system, a feature flag), drive it yourself with panel.setStatic(true) and read panel.isStatic. A panel:staticchange event fires on every switch, with detail.static.
Default: false
JavaScript
{ static: '(min-width: 769px)' }{ static: true }
Data attribute
data-panel-static="(min-width: 769px)"data-panel-static
Web Component attribute
panel-static="(min-width: 769px)"panel-static
Turn the CSS size animation on or off.
Default: true
JavaScript
{ transitions: true }{ transitions: false }
Data attribute
data-panel-transitions="false"
Web Component attribute
panel-transitions="false"
Grouping panels
data-panel-group is just a scope marker. On its own it does nothing, but it is where the group-level options live (data-panel-close-siblings, data-panel-persist, data-panel-deeplink), and it gives you a handle to target the group in CSS.
<div data-panel-group>
<h4>
<button aria-controls="acc-1" aria-expanded="false">Question 1</button>
</h4>
<div id="acc-1" data-panel>
<div class="panel-wrapper">Answer 1</div>
</div>
<h4>
<button aria-controls="acc-2" aria-expanded="false">Question 2</button>
</h4>
<div id="acc-2" data-panel>
<div class="panel-wrapper">Answer 2</div>
</div>
</div>
Add data-panel-close-siblings so opening one panel closes the rest. That is the line that turns a group into an accordion.
<div data-panel-group data-panel-close-siblings>
<h4>
<button aria-controls="acc-1" aria-expanded="false">Question 1</button>
</h4>
<div id="acc-1" data-panel>
<div class="panel-wrapper">Answer 1</div>
</div>
<h4>
<button aria-controls="acc-2" aria-expanded="false">Question 2</button>
</h4>
<div id="acc-2" data-panel>
<div class="panel-wrapper">Answer 2</div>
</div>
</div>
Without data-panel-close-siblings the panels are independent: any number can be open at once. The group element can be any tag, and you can have as many separate groups on a page as you like.
Implicit trigger wiring
When a button sits right next to its panel in the markup, you can skip the id / aria-controls pairing altogether. Put data-panel-trigger on the button and Panel finds the next [data-panel] sibling, gives it a stable generated id, and wires up aria-controls and aria-expanded for you.
<button data-panel-trigger>Toggle</button>
<div data-panel>
<div class="panel-wrapper">Content</div>
</div>
The data-panel-trigger button can also sit inside a heading. If it is a direct child of a heading (h1–h6) that is the panel’s sibling, Panel wires it the same way, which keeps an accordion’s heading markup clean:
<h3>
<button data-panel-trigger>Toggle</button>
</h3>
<div data-panel>
<div class="panel-wrapper">Content</div>
</div>
This earns its keep on repeated collapse patterns (accordions, catalogues), where inventing a unique id for every item is pure boilerplate. The generated ids (panel-1, panel-2, and so on) stay stable for the life of the page load and count up in DOM order.
If the panel already has an id, Panel leaves it alone. data-panel-trigger only comes up with one when it is missing.
Events
Panel dispatches custom events on the panel element as it opens and closes. They all bubble, and each carries a detail object with a trigger: the element that started the action, or null when your script did it.
| Event | Fires when |
|---|---|
panel:beforeopen |
Before the open animation starts. Attach a |
panel:opening |
The open animation has started. |
panel:opened |
The open animation has finished. |
panel:closing |
The close animation has started. |
panel:closed |
The close animation has finished. |
document.querySelector('#my-panel').addEventListener('panel:opened', e => {
console.log('Opened by', e.detail.trigger);
});
// Async content loading
document.querySelector('#my-panel').addEventListener('panel:beforeopen', e => {
e.detail.promise = fetch('/content').then(r => r.text()).then(html => {
e.currentTarget.querySelector('.panel-wrapper').innerHTML = html;
});
});
CSS variables
Timing and look come from CSS custom properties, set on the element or any ancestor. Every duration needs a unit (0s, never a bare 0, or the browser ignores it). The same variables work on : set them on the element, or target ps-panel in CSS.
[data-panel] {
--ps-open-speed: 0.25s; /* open transition duration */
--ps-open-timing: ease-in-out; /* open easing function */
--ps-close-speed: 0.25s; /* close transition duration */
--ps-close-timing: ease-in-out; /* close easing function */
--ps-panel-width: 320px; /* inner .panel-wrapper width (horizontal panels) */
--ps-closed-opacity: 0; /* opacity of .panel-wrapper when closed/closing; set to 1 to disable the fade */
}