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.
Install
Section titled “Install”pnpm add @agencecinq/tabsImport 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>Options
Section titled “Options”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. |
Methods
Section titled “Methods”| Method | Description |
|---|---|
activateTab(index: number) |
Activates a tab programmatically (useful after canceling tabs:before-activate). |
Events
Section titled “Events”| 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. |
Examples
Section titled “Examples”Automatic activation (data-delay)
Section titled “Automatic activation (data-delay)”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>Example
Section titled “Example”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.
The dracolich panel becomes active after a short delay once focus moves to its tab.
Async loading with tabs:before-activate
Section titled “Async loading with tabs:before-activate”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); });});Example
Section titled “Example”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.
Ancient Gold Dragon
Consulting bestiary...
Open this tab to fetch another creature entry.
Destroy & recreate a <cinq-tabs> instance
Section titled “Destroy & recreate a <cinq-tabs> instance”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();});Example
Section titled “Example”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.
Looks like a boulder until it animates. Then the fists start flying.
Add tabs at runtime
Section titled “Add tabs at runtime”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();});Example
Section titled “Example”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.
Trolls are horrid carnivores found in all climes, from arctic wastelands to tropical jungles.
Jermlaine are a diminutive humanoid race that dwells in tunnels and ambushes hapless adventurers.
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); });});Interactive example
Section titled “Interactive example”Barracudas are swift marauders of warm salt waters.
A dracolich is an undead dragon created by forbidden rites.
A galeb duhr looks like a boulder until it moves.
Hash syncing
Section titled “Hash syncing”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.
Cloakers lurk like ordinary cloaks until they unfold. Activating this tab sets
#hash-cloaker in the address bar.
Nested tabs
Section titled “Nested tabs”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.
Crimbils are malicious fey rumored to be a corrupted bloodline of wood gnomes.
Plush golems are every parent’s nightmare. They start as toys and quickly become full-fledged family members.
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>