Setting options

JavaScript options

JS
Panel.init({ axis: 'horizontal', closeOnResize: true });

const panel = new Panel('#my-panel', {
	axis: 'vertical',
	transitions: true,
	autoFocus: 'first',
	returnFocus: true
});

Data attributes

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

HTML
<!-- 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 movement
  • true: 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

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 element
  • data-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.

HTML
<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):

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

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

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

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

HTML
<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 (h1h6) that is the panel’s sibling, Panel wires it the same way, which keeps an accordion’s heading markup clean:

HTML
<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 Promise to detail.promise to defer opening until async content is ready. Also exposes detail.signal (aborted if the open is cancelled).

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

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 */
}