Skip to content

Tabs

Terminal window
pnpm add @agencecinq/tabs

Import once:

import "@agencecinq/tabs";

Then write the WAI‑ARIA markup:

<cinq-tabs data-tabs-hash="false" data-tabs-delay="0">
<div role="tablist" aria-label="Example tabs">
<button
id="tab-home"
role="tab"
aria-selected="true"
aria-controls="panel-home"
>
Home
</button>
<button
id="tab-project"
role="tab"
aria-selected="false"
aria-controls="panel-project"
tabindex="-1"
>
Project
</button>
</div>
<section
id="panel-home"
role="tabpanel"
aria-labelledby="tab-home"
tabindex="0"
>
</section>
<section
id="panel-project"
role="tabpanel"
aria-labelledby="tab-project"
tabindex="0"
hidden
>
</section>
</cinq-tabs>

Configure via data attributes:

AttributeTypeDefaultDescription
data-tabs-hashtrue/falsetrueSync the active tab id to location.hash on activation.
data-tabs-delaynumber (ms)0When > 0, arrow key navigation activates the focused tab after the given delay.
MethodDescription
activateTab(index: number)Activates a tab programmatically (useful after canceling tab-before-activate).
EventCancelableDetail payloadDescription
tab-before-activateYes{ index, controls, element }Fired before activation; call event.preventDefault() to override.
tab-activateNo{ controls, element }Fired when a tab has been activated.
tab-deleteNo{ controls, element }Fired when a deletable tab has been removed.

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

<cinq-tabs data-tabs-hash="true" data-tabs-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-tabs-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("tab-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 tab-before-activate to the public D&D 5e monsters API and only activates the tab once the monster data has been fetched and rendered:

Adult Black Dragon

Loading from D&D API…

This panel will be populated from a public D&D monsters 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.

Destroy removes ARIA state + listeners, Create re-initializes.

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.

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

Barracudas are swift marauders of warm salt waters.

When data-tabs-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-tabs-hash="false"></cinq-tabs>
</div>