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 you force a drawer to look permanent with CSS alone, 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 what is already expanded. The page looks right and reads wrong.

Static is not "open the panel", it is "stop being a disclosure". Going static, the panel:

  • drops inert, so its content is really in the accessibility tree
  • removes aria-expanded and aria-controls from the trigger (removes, does not set to false), since the button no longer expands or controls anything
  • hides that trigger through the stylesheet, so it holds no tab stop

Going back the other way restores all three. And the panel returns closed, never open over your content.

Two things happen, in two places. The button is hidden by CSS, so you can bring it back with display: revert and reuse it for something else. The ARIA is removed by JavaScript, and that part you cannot turn off. There are two reasons it has to work this way. If you do bring the button back, a visible button must not still say it controls a panel that no longer opens or closes. And you might not use our stylesheet at all. So whether the markup is honest can never depend on the CSS.

One consequence: aria-controls was how the panel found its own trigger, so on the way into static it stamps data-ps-for="panel-id" and finds it by that instead. The hook lives only while aria-controls is away, and an ordinary panel never carries one. If your own code looks a static panel's trigger up, look for data-ps-for.

setStatic() does all of this 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. The setStatic() call is wrapped in requestAnimationFrame, because setStatic() changes the DOM, and changing layout inside a ResizeObserver callback makes the browser warn about a resize loop. Doing it in the next frame keeps the callback clean:

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

let frame;
new ResizeObserver(([entry]) => {
	cancelAnimationFrame(frame);
	frame = requestAnimationFrame(() => {
		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 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.