The exception to unstyled

Panel and PanelSet ship no styling, with one exception: the loading indicator. While async/remote/slow content loads, you get a spinner, and it has a size, a thickness, a speed and colors.

The spinner is there because the library needs it. Panel and PanelSet need to show at least something to indicate to the user that there is some waiting to be expected. That "at least something" is the spinner. You do not have to use it, but you do have to show something. NOT showing anything if there is async/remote/slow content loading in the background is worse.

You will probably want one of three things, and they are the three sections below:

  1. The default spinner, in the panel area. You get it out of the box.
  2. Your own spinner, with your own size, thickness, speed and colors. You can do that with CSS variables.
  3. Your own indicator: a spinner in the trigger, a progress bar, skeleton content, a label change. You can do that with the state the library exposes.

The library supplies the state, not the look. .is-loading on the panel, aria-busy and .is-trigger-loading on the trigger. The spinner is only the default way of showing that state. Replace it and nothing else changes.

1. The default spinner

Nothing to set up. Load content asynchronously and the spinner appears in the panel area, in both components:

Panel

PanelSet

Key Features

  • This is our most important feature ever!

It only appears if the load takes longer than loadingDelay (320ms by default), so a fast load opens straight to its content without a flash of spinner. While it shows, the panel holds loadingHeight (150px by default) so there is somewhere for it to live, and the old content dims behind it.

JavaScript
new Panel('#my-panel', {
	loadingDelay: 320,   // wait this long before showing anything
	loadingHeight: 150   // the room it holds while it waits
});

2. Your own spinner

Same spinner, your values. Every part of it is a CSS variable, so nothing needs to be rebuilt. The pair below is the demo above with five variables changed:

Panel

PanelSet

Key Features

  • This is our most important feature ever!
CSS
.my-panel {
	--ps-spinner-size: 24px;
	--ps-spinner-thickness: 8px;
	--ps-spinner-track-color: pink;
	--ps-spinner-color: limegreen;
	--ps-spinner-speed: 1.4s;
}

Set them wherever they inherit from: on :root for every panel on the page, or on one element to change just that one.

CSS variables

--ps-spinner-size
How big the circle is. Default 40px.
--ps-spinner-thickness
How thick the ring is. Default 4px.
--ps-spinner-track-color
The ring that stays put. Default rgba(0, 0, 0, 0.1).
--ps-spinner-color
The arc that travels, which is what reads as movement. Default rgba(0, 100, 255, 0.8).
--ps-spinner-speed
One full turn. Default 0.8s.
--ps-spinner-fade-in-speed
How long it takes to fade up once it appears, so it does not arrive as a flash. it is unset by default, which means it follows whatever the spinner appears alongside: the content dimming in a PanelSet (--ps-loading-dim-duration), or the panel opening in a Panel (--ps-open-speed). Give it a value to pick one number for both.
--ps-spinner-fade-out-speed
How long it takes to fade away once the content is there. Default 0.2s. Separate from the one above, so the spinner can come up gently and leave quickly.
--ps-spinner-top / --ps-spinner-bottom
Defaults: 50% and auto.
--ps-spinner-left / --ps-spinner-right
Defaults: 50% and auto.
--ps-spinner-transform
Default translate(-50%, -50%), which pulls the circle back by half its own size so that 50%/50% is its middle rather than its top-left corner. To place it against an edge instead, set this to translate(0, 0). It has to stay a transform function: none is not valid here, because the spin is composed on top of it.
--ps-loading-panel-opacity
How far the old content fades back while the new content loads. Default 0.1. Set it to 1 to leave the content alone.
--ps-loading-dim-duration
How long that fade back takes (PanelSet). Follows --ps-panel-out-speed unless you set it, and the spinner fades in over the same time, so the two read as one cross-fade.

3. Your own indicator

If a spinner in the panel area is not what you want at all, take it out and show the wait yourself. In the pair below the spinner sits in the trigger instead, the button of the Panel and the tab of the PanelSet, and the content area holds no room for one:

Panel

PanelSet

Key Features

  • This is our most important feature ever!

Two things make that work:

  • customIndicator tells the library you are showing the state yourself, so it does not show its spinner.
  • loadingHeight: 0 is the other half: with the indicator on the trigger, the content area does not need to hold space for one, so it stays collapsed until the real content arrives. That is why the demos above open straight to their content.
JavaScript
new Panel('#my-panel', {
	customIndicator: true,
	loadingHeight: 0
});
new PanelSet('#my-panelset', {
	customIndicator: true,
	loadingHeight: 0
});

Or use it as one-offs, as data-attributes:

HTML
<div data-panel data-panel-custom-indicator data-panel-loading-height="0"> … </div>
<div data-panelset data-panel-custom-indicator data-loading-height="0"> … </div>

After that, you can use the following states and classes for styling:

.is-loading
On the panel or the panelset, while its content loads.
aria-busy="true"
On every trigger pointing at that panel. This is the real accessibility signal for a control whose content is being fetched, so it is worth keying off rather than the class.
.is-trigger-loading
On those same triggers, for when you want a class instead.
CSS
/* show it on the trigger: a button, or a tab */
[aria-busy="true"]::after {
	content: "";
	display: inline-block;
	width: 14px;
	height: 14px;
	margin-left: 0.5em;
	border: 2px solid rgb(0 0 0 / 0.15);
	border-top-color: currentColor;
	border-radius: 50%;
	animation: my-spin 0.6s linear infinite;
}

@keyframes my-spin {
	to { rotate: 360deg; }
}

Both components mark that state while content loads, and clear it the moment the load settles, whether it finishes, is cancelled, or fails.

It does not have to be a spinner. The state is just a state: fade the trigger, put a progress bar above the panel, show skeleton content, swap the label. The library does not care what you show.