Live demo

Drag the handle on the right, or give it focus and use the arrow keys. Above 260px the panel is static: it is simply open, and the button is gone. Below that it is a disclosure again, and the button comes back.

The markup underneath is live. Watch aria-expanded and inert disappear as data-ps-static arrives, because that is what this option really is. It is not a layout trick, it is a change of what the thing is.

Content

Measuring…

Live HTML

What a screen reader is told

If a developer tries to force a drawer to look permanent with CSS alone, that will not work. The library still thinks it is a closed disclosure, so the markup keeps saying so: inert hides the sidebar everyone can see, and aria-expanded="false" claims a button can expand something that is already expanded. The page looks right and reads wrong.

Static is not "open the panel", it is "stop being a disclosure". The JavaScript takes inert off the panel and removes aria-expanded from the trigger. Not set to true: gone, because there is nothing left to expand. The stylesheet then hides that trigger, since a button that can do nothing should not hold a tab stop. Go back the other way and the panel lands closed with both attributes restored.

Those are two separate layers, on purpose. Hiding the button is CSS, and you can take it back with display: revert to keep the button and give it another job. Removing aria-expanded is JavaScript, and it is not optional: a button you chose to keep must not go on claiming it expands a panel that cannot collapse, and your stylesheet may not load ours at all.

aria-controls goes as well, for the same reason. A button you keep and repurpose must not still say it controls this panel. So a static panel's trigger claims nothing about it at all: it is hidden, and if you unhide it, it is simply a button.

Which leaves the panel needing another way to recognise its own button, since aria-controls is exactly how it used to find one. So on its way into static it stamps a hook first: data-ps-for="panel-id". That, and not the ARIA, is how a static panel finds its triggers, which is how it can give aria-controls back when it becomes a disclosure again.

You never write data-ps-for yourself, and it lives exactly as long as aria-controls is away: it arrives as the panel goes static and leaves as soon as aria-controls comes back. An ordinary panel never carries one. But if you go looking for the trigger of a static panel in your own code, that is what to look for, because aria-controls will not be there.

setStatic() does all of the above when the breakpoint is not a viewport media query.

Do not name the panel after its button

If a panel can go static, do not label it with aria-labelledby pointing at its own trigger, which is the usual accordion pattern. The name survives (a name read through aria-labelledby still comes off an element that CSS has hidden, the one place hidden text counts), so nothing breaks. But the panel ends up named after a button that is no longer on the page.

Give it a name of its own instead, with aria-label, or point aria-labelledby at a heading inside the panel, which is content that stays. Panel never touches the label itself: stripping it would leave the panel with no name at all, which is worse than a stale one. With debug on, it says so in the console.

How it is driven

With a breakpoint

This is what you want almost every time. Put the media query in the markup and the library watches it for you, with no JavaScript of your own at all:

HTML
<div data-panel data-panel-static="(min-width: 769px)">

The breakpoint then lives in exactly one place. Your CSS does not repeat it, because the library marks the panel and its trigger with data-ps-static while the query matches, and you can style off that.

With setStatic()

The demo above cannot use a breakpoint. static takes a media query, and a media query only ever sees the viewport, not a box on the page. A container query cannot help either: CSS never hands those to JavaScript, and there is no matchContainer().

So you watch whatever you like and tell the panel what it is:

JavaScript
const box = document.getElementById('static-box');
const panel = document.getElementById('static-panel').panel;

new ResizeObserver(([entry]) => {
	panel.setStatic(entry.contentRect.width >= 260);
}).observe(box);

Crossing the threshold never animates, so dragging the handle does not look like the panel opening and closing over and over. And setStatic() with the state it is already in does nothing, so calling it on every resize frame is fine.

Reach for it only when the thing you are reacting to is something the library cannot see for itself: a container query, your own breakpoint system, a feature flag.

Reading the state

panel.isStatic tells you where it stands, and panel:staticchange fires on every switch, with detail.static. That event is what the markup above listens to.

JavaScript
panel.element.addEventListener('panel:staticchange', (e) => {
	console.log(e.detail.static ? 'plain content now' : 'a disclosure again');
});

A static panel is not an open panel. It has no open or closed state at all, so open(), close() and toggle() do nothing, and persist and deepLink write nothing. When it becomes a disclosure again it starts as being closed, because a panel that reappears already open over your content is never what you want.