Skip to content

Tabs

Tabs organize content into selectable panels. <cinq-tabs> wires keyboard navigation between tab triggers, aria-selected / hidden state, optional URL hash sync, and activation events.

Inspired by the WAI-ARIA Authoring Practices tabs pattern.

Terminal window
pnpm add @agencecinq/tabs

Import once:

import "@agencecinq/tabs";

Then write the WAI-ARIA markup explicitly. The component adds interactivity only.

HTML is the source of truth. The component will not auto-set role, auto-migrate attributes, or warn about missing labels. Run an a11y linter (axe, Lighthouse) to catch invalid markup.

<cinq-tabs data-hash="false" data-delay="0">
<div role="tablist" aria-label="Bestiary tabs">
<button
id="tab-troll"
role="tab"
aria-selected="true"
aria-controls="panel-troll"
>
Troll
</button>
<button
id="tab-dracolich"
role="tab"
aria-selected="false"
aria-controls="panel-dracolich"
tabindex="-1"
>
Dracolich
</button>
</div>
<section
id="panel-troll"
role="tabpanel"
aria-labelledby="tab-troll"
tabindex="0"
>
...
</section>
<section
id="panel-dracolich"
role="tabpanel"
aria-labelledby="tab-dracolich"
tabindex="0"
hidden
>
...
</section>
</cinq-tabs>

Configure via data attributes:

Attribute Type Default Description
data-hash true/false true Sync the active tab id to location.hash on activation.
data-delay number (ms) 0 When > 0, arrow key navigation activates the focused tab after the given delay.
Method Description
activateTab(index: number) Activates a tab programmatically (useful after canceling tabs:before-activate).
Event Cancelable Detail payload Description
tabs:before-activate Yes { index, controls, element } Fired before activation. Call event.preventDefault() to override.
tabs:activate No { controls, element } Fired when a tab has been activated.
tabs:delete No { controls, element } Fired when a deletable tab has been removed.

When data-delay is greater than 0, moving focus with arrow keys will automatically activate the tab after the given delay (in ms):

<cinq-tabs data-hash="true" data-delay="300">
<div role="tablist" aria-label="Monster tabs">
<button
id="tab-troll"
role="tab"
aria-selected="true"
aria-controls="panel-troll"
>
Troll
</button>
<button
id="tab-dracolich"
role="tab"
aria-selected="false"
aria-controls="panel-dracolich"
tabindex="-1"
>
Dracolich
</button>
</div>
<section
id="panel-troll"
role="tabpanel"
aria-labelledby="tab-troll"
tabindex="0"
>
...
</section>
<section
id="panel-dracolich"
role="tabpanel"
aria-labelledby="tab-dracolich"
tabindex="0"
hidden
>
...
</section>
</cinq-tabs>

Below, data-delay="300" means that when you move focus with Left/Right arrows, the tab will auto‑activate after 300 ms:

Trolls regenerate quickly. Automatic activation makes cycling through their stats fast with only the arrow keys.

const tabsEl = document.querySelector("cinq-tabs");
tabsEl.addEventListener("tabs:before-activate", event => {
const { index, controls } = event.detail;
// Cancel the built-in activation
event.preventDefault();
// Simulate async work (fetch data, animate, etc.)
fetch(`/api/panels/${controls}`)
.then(response => response.json())
.then(() => {
// When ready, activate the tab programmatically
tabsEl.activateTab(index);
});
});

The live demo below wires tabs:before-activate to a public monster reference API and only activates the tab once the creature data has been fetched and rendered:

Adult Black Dragon

Consulting bestiary...

This panel is populated from a public monster reference API on first activation.

const tabsEl = document.querySelector("cinq-tabs.js-tabs");
const destroyBtn = document.querySelector(".js-tabs-destroy");
const createBtn = document.querySelector(".js-tabs-create");
destroyBtn?.addEventListener("click", () => {
tabsEl.destroy();
});
createBtn?.addEventListener("click", () => {
tabsEl.init();
});

Destroy / Create

Useful when you mutate the DOM and want to re-bind events.

Trolls regenerate 3 hp per round unless burned or acid-treated.

const tabsEl = document.querySelector("cinq-tabs.js-tabs-add");
const list = tabsEl.querySelector('[role="tablist"]');
const panels = tabsEl.querySelector("[data-tab-panels]");
const addBtn = document.querySelector(".js-tabs-add");
const labelInput = document.querySelector(".js-tabs-label");
const contentInput = document.querySelector(".js-tabs-content");
addBtn?.addEventListener("click", () => {
const label = labelInput.value.trim();
const content = contentInput.value.trim();
if (!label || !content) return;
const id = label.toLowerCase().replace(/\s+/g, "-");
tabsEl.destroy();
list.insertAdjacentHTML(
"beforeend",
`<button
type="button"
role="tab"
aria-selected="false"
aria-controls="${id}-panel"
id="${id}"
tabindex="-1"
>
${label}
</button>`,
);
panels.insertAdjacentHTML(
"beforeend",
`<section
tabindex="0"
role="tabpanel"
aria-labelledby="${id}"
id="${id}-panel"
hidden
>
${content}
</section>`,
);
tabsEl.init();
});

Add tabs at runtime

The pattern is: destroy(), mutate DOM, init().

The galeb duhr is a curious boulder-like creature with appendages that act as hands and feet.

Listening to tabs:activate and tabs:delete

Section titled “Listening to tabs:activate and tabs:delete”
const tabsEl = document.querySelector("cinq-tabs");
tabsEl.querySelectorAll('[role="tab"]').forEach(tab => {
tab.addEventListener("tabs:activate", ({ detail }) => {
const { controls, element } = detail;
console.log("Activated tab:", controls, element);
});
tab.addEventListener("tabs:delete", ({ detail }) => {
const { controls, element } = detail;
console.log("Deleted tab:", controls, element);
});
});

Barracudas are swift marauders of warm salt waters.

When data-hash="true", activating a tab updates location.hash with the tab id. Reloading the page with that hash will restore the active tab:

The galeb duhr looks like a boulder until it moves. Its tab id #hash-galeb is reflected in the URL when active.

You can safely nest <cinq-tabs> as long as each tablist / panel group is scoped within its own component.

Inner tabs are fully independent: keyboard navigation and ARIA state are scoped to each cinq-tabs instance.

The adherer looks like a mummy, with folds of off-white skin resembling filthy bandages.

If the tablist inherits direction: rtl (e.g. dir="rtl"), left/right arrow navigation follows the reading direction.

<div dir="rtl">
<cinq-tabs data-hash="false">...</cinq-tabs>
</div>