What PanelSet does automatically

PanelSet keeps the selected state in sync on every [aria-controls] trigger, setting it the moment a switch is committed, well before the animation ends. A real tab (a role="tab" inside a role="tablist") gets aria-selected; any other trigger (a nav link, a plain button) gets aria-current on the active one instead, because aria-selected is only valid on tabs. It also applies inert to a closed container and to panels that are not visible yet during a transition, so nothing offscreen stays reachable.

When PanelSet automatically
On initialisation Sets the selected state on all [aria-controls] triggers to match the active panel: aria-selected on real tabs, aria-current on any other trigger
On init / refresh Links each panel to the tab that controls it via aria-labelledby (generating a tab id if needed), unless the panel already has a name
Tab is switched Updates that state (aria-selected or aria-current) immediately when the new tab is committed (before the animation ends)
Closable panelset is closed Adds inert to the container, removing it and its contents from the tab order entirely

What you still need to provide

  • Keyboard navigation (arrow keys, Home, End) if you are using a real role="tablist" pattern. You can use PanelControl to automate this for you.
  • An accessible name for any panel with no controlling tab (give them aria-label). Panels that do have a tab get aria-labelledby for free (see manageLabels, the auto-adding of the attribute can be switched off).
  • The initial selected state in your HTML (aria-selected on real tabs, or aria-current on other triggers). PanelSet overrides it on init, but it keeps the no-JS fallback correct.

Required markup

Panel elements

Every panel needs role="tabpanel" and a unique id.

HTML
<div data-panelset>
	<div role="tabpanel" id="panel-1" aria-labelledby="tab-1"><!-- Content --></div>
	<div role="tabpanel" id="panel-2" aria-labelledby="tab-2"><!-- Content --></div>
</div>

Tab buttons

For a real tab interface, wrap the buttons in a role="tablist", and give each one role="tab" and aria-controls.

HTML
<div role="tablist" aria-label="Settings sections">
	<button role="tab" id="tab-1" aria-controls="panel-1" aria-selected="true">Overview</button>
	<button role="tab" id="tab-2" aria-controls="panel-2" aria-selected="false">Security</button>
</div>

Focus management

Switch panels with the keyboard to see autoFocus at work. Tab to a button, press Enter, and watch where focus lands.

autoFocus: 'heading'

Focus moves to the first heading in the incoming panel. PanelSet adds tabindex="-1" to that heading for you if it has not got one (a heading cannot take focus otherwise).

Key Features

Switch to this panel with keyboard to see focus land on this heading. Here is A focusable link in this panel.

autoFocus: 'first'

Focus moves to the first focusable element. Ideal for wizard steps, where you want the user in the form straight away.

Step 1. Your name

Focus will land on the input below when you arrive here via keyboard.

autoFocus: 'input'

Focus moves to the first input, select, or textarea in the incoming panel.

Note: this one moves focus for everyone, not just keyboard users.

Welcome

We have another panel for you.

autoFocus options

Panel and PanelSet take the same autoFocus values.

Value What it focuses Best for
'heading' First h1-h6 in panel (gets tabindex="-1" automatically) Standard tabs with headings
'first' First focusable element (button, link, input) Wizard steps, forms
true The panel element itself Panels with aria-label
'input' First form field (input, select, textarea) Forms, bypasses keyboard-only check
false Nothing (no focus movement) Page load, mouse-only interfaces

Keyboard vs. mouse detection

Pass the event to show() and PanelSet only moves focus on keyboard activation (Enter or Space). Mouse clicks skip the focus move, which keeps the page from jumping unexpectedly.

JS
button.addEventListener('click', (event) => {
	panelSet.show('panel-2', {
		event,              // pass the event
		autoFocus: 'heading'
	});
	// keyboard: focuses heading
	// mouse click: no focus movement
});

Best practice: always pass event to show(). Without it, autoFocus fires on every activation, mouse clicks included, which can scroll the page out from under a mobile user. You do not want that.

Labeling panels

Every panel needs an accessible name, so a screen reader can say what it is showing. PanelSet does this for tabbed panels automatically (manageLabels); the patterns below are what it produces, plus what to write yourself for panels that have no tab.

aria-labelledby (recommended for tabs)

Links the panel to its tab button, so the button’s text becomes the panel’s accessible name. PanelSet applies this for you (it comes up with a tab id if needed); write it by hand only to override, or when manageLabels is off.

HTML
<button role="tab" id="tab-overview" aria-controls="panel-overview">Overview</button>

<div role="tabpanel" id="panel-overview" aria-labelledby="tab-overview">
	<!-- Content-->
</div>

aria-label (for panels without tabs)

For wizard steps, sub-panels, or any panel with no tab of its own.

HTML
<div role="tabpanel" id="step-2" aria-label="Step 2: Contact Information">
	<!-- Content-->
</div>