TabsATM
Organize content into switchable panels. Uses WAI-ARIA Tabs pattern with roving tabindex and arrow key navigation. Available in pill and line variants.
On this page (15)
§Pill Variant (Default)
Default style with --muted background. Active tab gets --background fill and shadow.
§Line Variant with Disabled Tab
Add data-variant='line' to the .tab-list for an underline style. Disabled tabs use the native disabled attribute.
§Vertical Orientation
Set aria-orientation='vertical' on the tablist. CSS uses :has() to switch to a side-by-side layout automatically - a settings nav with horizontal labels. For tabs whose labels run vertically along the panel edge, use data-side (below).
§Attached to the card
data-variant="attached" on the .tab-list turns the tabs into folder tabs joined to the panel's .card: the selected tab takes the card surface and covers the card border where they meet, so tab and card read as one shape. The card corner the tabs grow from is squared.
§Tabs below the card
data-side="bottom" on .tabs puts the tab row under the panel it switches - here attached and right-aligned with data-align="end" (a player's lyrics / queue / devices switcher). The DOM order stays tablist-first; only the visual side changes.
§Vertical, attached - left and right
data-side="left" or "right" stacks the tabs along that edge of the card, and their labels turn vertical with it - read bottom to top on the left, top to bottom on the right, like book spines (tabs.ts sets aria-orientation="vertical", so Up / Down move between tabs). data-align picks where they start: the left one from the top (start), the right one from the bottom (data-align="end").
§Side and alignment
Every combination, live: data-side (top / bottom / left / right) sets the edge, data-align (start / center / end) where the tabs cluster along it, and any variant - pill, line or attached - follows the side. Pick one below; the tabs keep working, and the arrow keys switch to Up / Down on the left and right.
§Icons and emoji
Put an icon in front of the label: an svg, an img, a lucide <i data-lucide>, or an emoji in a <span class="tab-icon">. It sits inline before the text, 0.5em apart, sized 1em - and turns with the label when the tabs run vertically, so icons and emoji read the same way as the text.
§Long labels
When the labels need more room than there is, they shrink and end in an ellipsis - the full text stays the accessible name. Horizontal tabs never grow wider than their container; vertical ones never taller than the panel beside them (the panel sets the height, the tab column fills it).
§A tab in its own color
Any tab can carry its own text color - here the destructive color marks a danger zone. It holds in every state, selected or not.
§Disabled tabs and the State API
disabled is a state, not only a style: a native disabled trigger is skipped by the arrow keys and cannot be selected, and api.setState('disabled') / setState('default') switch it at runtime - a selected tab that gets disabled hands the selection on. The tablist has its own api too: setState('active', { index }) picks the selected tab, and each tab's setState(currentState, { label, icon }) renames and re-icons it without touching the selection.
§Sizes
Set data-size on the .tab-list; triggers scale with it - the default equals md.
§States
Named states via the shared State API, driven per instance through the bound api:
default- on a tab, enabled and not picked (the authored selection stands; label and icon stay); on the tablist, everything as authored - selection, disabled flags, labels, icons (the init snapshot)active- on a tab, this tab selected and its panel visible (selection stays exclusive); on the tablist, the tab atconfig.index(orconfig.id) selecteddisabled- on a tab, not selectable and skipped by the arrow keys (a selected tab hands the selection on); on the tablist, every tab disabled- config
{ label, icon }on a tab renames / re-icons it (a lucide name mounts<i data-lucide>, anything else - an emoji - a.tab-iconspan,''removes it); re-entering the current state with a config applies just the config
The demo element carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/tabs-{state}.png.
Machine contract - verified against tabs.schema.json by bun run verify:
| State | Type | Values | Default | Description |
|---|---|---|---|---|
selected | number | — | 0 | Index of the selected tab - the tablist's own api: setState('active', { index }), read back from getState().config.index. |
active | boolean | true, false | false | The demo’s second tab activated (its panel shown, others hidden) - the State API binds per .tab-trigger. |
disabled | boolean | true, false | false | The second tab disabled - setState('disabled'); unchecking enables it with setState('default'). |
label | string | — | — | The second tab's text - setState(current, { label }), selection untouched. |
icon | string | — | — | The second tab's icon: a lucide name, an emoji, or empty for none - setState(current, { icon }). |
§CSS view file
/* -- Tabs component -------------------------------------------- */@layer components { /* -- Default (pill) variant ----------------------------------- */ /* An inline GRID, one minmax(0, max-content) track per tab: when the row is too narrow, every track grows from 0 at the same pace, so short labels reach their full width first and only the long ones are cut ("…") - flex-shrink would clip a short "Churn" as eagerly as a long label. */ .tab-list { display: inline-grid; grid-auto-flow: column; grid-auto-columns: minmax(0, max-content); align-items: center; /* never wider than its container: the triggers shrink and ellipsize */ max-width: 100%; min-width: 0; box-sizing: border-box; background: var(--muted); border-radius: var(--radius-lg); padding: 0.25rem; gap: 0.125rem; /* -- Sizes: trigger font + padding scale (set data-size on .tab-list). :where() keeps these at one class of specificity over the plain .tab-trigger base. md == each variant's own base padding, so md is selectable everywhere without a visual change. Paddings are logical (block / inline), so they turn with the vertical labels of side tabs. The line variant's trigger base (0.5rem/1rem, nested in [data-variant="line"]) outranks the :where() rules, so its overrides are un-:where()'d (two attrs). */ &:where([data-size="xs"]) .tab-trigger { padding-block: 0.25rem; padding-inline: 0.5rem; font-size: 0.75rem; } &:where([data-size="sm"]) .tab-trigger { padding-block: 0.3125rem; padding-inline: 0.625rem; font-size: 0.8125rem; } &:where([data-size="md"]) .tab-trigger { padding-block: 0.375rem; padding-inline: 0.75rem; font-size: 0.875rem; } &:where([data-size="lg"]) .tab-trigger { padding-block: 0.5rem; padding-inline: 1rem; font-size: 1rem; } &:where([data-size="xl"]) .tab-trigger { padding-block: 0.625rem; padding-inline: 1.25rem; font-size: 1.125rem; } &[data-variant="line"][data-size="xs"] .tab-trigger { padding-block: 0.25rem; padding-inline: 0.5rem; } &[data-variant="line"][data-size="sm"] .tab-trigger { padding-block: 0.375rem; padding-inline: 0.75rem; } &[data-variant="line"][data-size="md"] .tab-trigger { padding-block: 0.5rem; padding-inline: 1rem; } &[data-variant="line"][data-size="lg"] .tab-trigger { padding-block: 0.625rem; padding-inline: 1.25rem; } &[data-variant="line"][data-size="xl"] .tab-trigger { padding-block: 0.75rem; padding-inline: 1.5rem; } /* -- Line variant --------------------------------------------- */ &[data-variant="line"] { background: transparent; border-radius: 0; padding: 0; gap: 0; border-bottom: 1px solid var(--border); & .tab-trigger { border-radius: 0; padding-block: 0.5rem; padding-inline: 1rem; border-bottom: 2px solid transparent; margin-bottom: -1px; &[aria-selected="true"] { background: transparent; color: var(--foreground); border-bottom-color: var(--primary); box-shadow: none; } } } /* -- Vertical orientation ------------------------------------ */ &[aria-orientation="vertical"] { grid-auto-flow: row; grid-auto-rows: minmax(0, max-content); grid-auto-columns: auto; align-items: stretch; width: auto; & .tab-trigger { text-align: start; } } } /* A trigger is a BLOCK box (not inline-flex): text-overflow only works on a block container's own line, and a flex container would wrap a bare label in an anonymous box that never ellipsizes. Icons and emoji ride inline in front of the label - svg / img / .tab-icon - so plain <button>Label</button> markup keeps working and a long label ends in "…" when the list runs out of room (horizontal and vertical alike). */ .tab-trigger { display: block; min-width: 0; min-height: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: center; line-height: 1.25rem; padding-block: 0.375rem; padding-inline: 0.75rem; border-radius: calc(var(--radius) * 0.6); font-size: 0.875rem; font-weight: 500; border: none; background: transparent; color: var(--muted-foreground); cursor: pointer; transition: all 150ms ease; outline: none; & :is(svg, img, .tab-icon) { display: inline-block; vertical-align: middle; margin-inline-end: 0.5em; line-height: 1; } & :is(svg, img) { inline-size: 1em; block-size: 1em; translate: 0 -0.0625em; } &[aria-selected="true"] { background: var(--background); color: var(--foreground); box-shadow: 0 1px 3px oklch(0 0 0 / 0.08), 0 0 0 1px var(--border); } &:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } &:disabled { cursor: not-allowed; opacity: 0.5; } } /* -- Layout: which side of the panel the tabs sit on --------- data-side="top|bottom|left|right" on .tabs (default top; a vertical tablist without data-side keeps the old settings-nav layout) and data-align="start|center|end" for where the tabs cluster along that edge (top/bottom: start = left; left/right: start = top). The DOM order never changes - tablist first - only the visual side. :where() keeps the side selectors at zero specificity, so variants override them cleanly. */ .tabs { display: flex; flex-direction: column; min-width: 0; & > .tab-list { align-self: flex-start; } &[data-align="center"] > .tab-list { align-self: center; } &[data-align="end"] > .tab-list { align-self: flex-end; } &:where([data-side="bottom"]) { & > .tab-list { order: 2; } & > .tab-content { margin-top: 0; margin-bottom: 0.75rem; } } /* the older aria-orientation-only markup: a settings nav beside the panel, horizontal labels (ellipsized past 40% of the width) */ &:where(:not([data-side]):has(> .tab-list[aria-orientation="vertical"])) { flex-direction: row; align-items: flex-start; gap: 1rem; & > .tab-list { max-width: 40%; } & > .tab-content { margin: 0; flex: 1; min-width: 0; } &:dir(rtl) { flex-direction: row-reverse; } } /* data-side left / right: a two-column grid. The tab column has height 0 + min-height 100% - it adds nothing to the row, so the PANEL decides the height and the column fills it: when the labels need more room than the panel is tall, they shrink and ellipsize. left / right are physical sides, also in RTL. */ &:where([data-side="left"], [data-side="right"]) { display: grid; grid-template-columns: auto minmax(0, 1fr); grid-template-areas: "list panel"; column-gap: 1rem; & > .tab-list { grid-area: list; grid-auto-flow: row; grid-auto-rows: minmax(0, max-content); grid-auto-columns: auto; align-items: stretch; align-self: stretch; align-content: start; height: 0; min-height: 100%; max-width: none; } &[data-align="center"] > .tab-list { align-content: center; } &[data-align="end"] > .tab-list { align-content: end; } & > .tab-content { grid-area: panel; margin: 0; min-width: 0; } /* the labels run vertically along the panel edge - right-hand tabs read top to bottom, left-hand ones bottom to top (like book spines; sideways-lr where supported, else top to bottom too). The logical trigger paddings turn with the writing mode, and icons and emoji turn with the label: text-orientation: sideways lays emoji on their side like any other glyph, and svg / img (which a vertical line would keep upright) rotate a quarter turn. */ & > .tab-list .tab-trigger { writing-mode: vertical-rl; text-orientation: sideways; text-align: center; & :is(svg, img) { rotate: 90deg; } } } &:where([data-side="right"]) { grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "panel list"; } &:where([data-side="left"]):dir(rtl) { grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "panel list"; } &:where([data-side="right"]):dir(rtl) { grid-template-columns: auto minmax(0, 1fr); grid-template-areas: "list panel"; } @supports (writing-mode: sideways-lr) { /* sideways-lr lays emoji on their side, but svg / img keep their orientation: a quarter turn back, so they point up the line */ &:where([data-side="left"]) > .tab-list .tab-trigger { writing-mode: sideways-lr; & :is(svg, img) { rotate: -90deg; } } } } /* line variant: the rule sits on the edge that faces the panel (physical sides for left / right - the trigger's own writing mode is vertical, so its logical inline-end would point down) */ .tabs[data-side="bottom"] > .tab-list[data-variant="line"] { border-bottom: none; border-top: 1px solid var(--border); & .tab-trigger { border-bottom: none; border-top: 2px solid transparent; margin-bottom: 0; margin-top: -1px; &[aria-selected="true"] { border-top-color: var(--primary); } } } .tabs:is([data-side="left"], [data-side="right"], :not([data-side]):has(> .tab-list[aria-orientation="vertical"])) > .tab-list[data-variant="line"] { border-bottom: none; & .tab-trigger { border-bottom: none; margin-bottom: 0; } } .tabs:is([data-side="left"], :not([data-side]):has(> .tab-list[aria-orientation="vertical"])) > .tab-list[data-variant="line"] { border-right: 1px solid var(--border); & .tab-trigger { border-right: 2px solid transparent; margin-right: -1px; &[aria-selected="true"] { border-right-color: var(--primary); } } } .tabs[data-side="right"] > .tab-list[data-variant="line"] { border-left: 1px solid var(--border); & .tab-trigger { border-left: 2px solid transparent; margin-left: -1px; &[aria-selected="true"] { border-left-color: var(--primary); } } } /* -- Attached variant: folder tabs joined to the panel's card -- data-variant="attached" on the .tab-list; each panel holds a .card. The selected tab takes the card surface and covers the card's border where they meet, so tab and card read as one shape. The card corner the tabs grow from is squared; every ROUNDED corner keeps the tabs a card radius (--radius-xl) away, so no tab ever sits on a curve - start keeps the far end clear, end the near one, center both. */ .tab-list[data-variant="attached"] { background: transparent; border-radius: 0; padding: 0; gap: 0.25rem; position: relative; z-index: 1; & .tab-trigger { border: 1px solid var(--border); background: var(--muted); box-shadow: none; border-radius: var(--radius-md) var(--radius-md) 0 0; &[aria-selected="true"] { background: var(--card); color: var(--card-foreground); box-shadow: none; border-bottom-color: var(--card); } } } .tabs:has(> .tab-list[data-variant="attached"]) { gap: 0; column-gap: 0; & > .tab-content { margin: 0; } /* top (default) / bottom: the tabs overlap the card border by 1px and stay clear of the rounded corner(s) along the row */ & > .tab-list[data-variant="attached"] { margin-bottom: -1px; margin-inline-end: var(--radius-xl); max-width: calc(100% - var(--radius-xl)); } &[data-align="end"] > .tab-list[data-variant="attached"] { margin-inline: var(--radius-xl) 0; } &[data-align="center"] > .tab-list[data-variant="attached"] { margin-inline: var(--radius-xl); max-width: calc(100% - 2 * var(--radius-xl)); } &[data-side="bottom"] > .tab-list[data-variant="attached"] { margin-top: -1px; margin-bottom: 0; & .tab-trigger { border-radius: 0 0 var(--radius-md) var(--radius-md); &[aria-selected="true"] { border-bottom-color: var(--border); border-top-color: var(--card); } } } /* left / right (and the legacy column): the gap runs down the column */ &:is([data-side="left"], [data-side="right"], :not([data-side]):has(> .tab-list[aria-orientation="vertical"])) { & > .tab-list[data-variant="attached"] { margin-block: 0; margin-inline: 0; max-width: none; grid-auto-flow: row; grid-auto-rows: minmax(0, max-content); grid-auto-columns: auto; padding-bottom: var(--radius-xl); & .tab-trigger { width: 100%; } } &[data-align="end"] > .tab-list[data-variant="attached"] { padding-block: var(--radius-xl) 0; } &[data-align="center"] > .tab-list[data-variant="attached"] { padding-block: var(--radius-xl); } } /* legacy column (a flex row): the card grows to the column's height */ &:not([data-side]):has(> .tab-list[aria-orientation="vertical"]) { & > .tab-list[data-variant="attached"] { max-width: 40%; } & > .tab-content:not([hidden]) { align-self: stretch; display: flex; flex-direction: column; & > .card { flex: 1; } } } &:is([data-side="left"], :not([data-side]):has(> .tab-list[aria-orientation="vertical"])) > .tab-list[data-variant="attached"] { margin-right: -1px; & .tab-trigger { border-radius: var(--radius-md) 0 0 var(--radius-md); &[aria-selected="true"] { border-bottom-color: var(--border); border-right-color: var(--card); } } } &[data-side="right"] > .tab-list[data-variant="attached"] { margin-left: -1px; & .tab-trigger { border-radius: 0 var(--radius-md) var(--radius-md) 0; &[aria-selected="true"] { border-bottom-color: var(--border); border-left-color: var(--card); } } } /* the card corner the aligned tabs grow from is square */ &:not([data-side]):not(:has(> .tab-list[aria-orientation="vertical"])):not([data-align="center"]):not([data-align="end"]) > .tab-content > .card { border-start-start-radius: 0; } &:not([data-side]):not(:has(> .tab-list[aria-orientation="vertical"]))[data-align="end"] > .tab-content > .card { border-start-end-radius: 0; } &[data-side="top"]:not([data-align="center"]):not([data-align="end"]) > .tab-content > .card { border-start-start-radius: 0; } &[data-side="top"][data-align="end"] > .tab-content > .card { border-start-end-radius: 0; } &[data-side="bottom"]:not([data-align="center"]):not([data-align="end"]) > .tab-content > .card { border-end-start-radius: 0; } &[data-side="bottom"][data-align="end"] > .tab-content > .card { border-end-end-radius: 0; } &:is([data-side="left"], :not([data-side]):has(> .tab-list[aria-orientation="vertical"])):not([data-align="center"]):not([data-align="end"]) > .tab-content > .card { border-top-left-radius: 0; } &:is([data-side="left"], :not([data-side]):has(> .tab-list[aria-orientation="vertical"]))[data-align="end"] > .tab-content > .card { border-bottom-left-radius: 0; } &[data-side="right"]:not([data-align="center"]):not([data-align="end"]) > .tab-content > .card { border-top-right-radius: 0; } &[data-side="right"][data-align="end"] > .tab-content > .card { border-bottom-right-radius: 0; } } /* -- Panel ---------------------------------------------------- */ .tab-content { margin-top: 0.75rem; &:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } &[hidden] { display: none; } } /* -- Accessibility ------------------------------------------ */ @media (prefers-reduced-motion: reduce) { .tab-trigger { transition: none; } } @media (forced-colors: active) { .tab-trigger[aria-selected="true"] { border: 2px solid Highlight; box-shadow: none; } .tab-trigger:disabled { color: GrayText; } .tab-list[data-variant="line"] .tab-trigger[aria-selected="true"] { border: none; border-bottom: 2px solid Highlight; } }}§JavaScript view file
Panel switching + WAI-ARIA keyboard navigation. Arrow keys cycle tabs, Home/End jump to first/last, disabled tabs are skipped automatically.
// -- Tabs -----------------------------------------------------// ARIA-compliant keyboard navigation for [role="tablist"] elements, plus the// named-state API (AGENTS.md "State API"), bound at two levels:// - per tab trigger: default / active / disabled, config { label, icon }// - per tablist: default / active / disabled, config { index }// so agents/tests can pick the selected tab, disable a tab, or rename and// re-icon it by name - without knowing the markup.// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.import { defussGlobals } from '../../shared/state-api.js';const df$ = defussGlobals();// per tab: 'default' = enabled, not picked (the authored selection stands),// 'active' = selected, 'disabled' = not selectable// per tablist: 'default' = everything as authored (selection, disabled flags,// labels, icons), 'active' = the tab at config.index selected,// 'disabled' = every tab disabledconst tabsStates = ['default', 'active', 'disabled'];// -- label + icon (content state) ---------------------------------------------// The icon is the trigger's leading svg / img / <i data-lucide> / .tab-icon;// the label is its .tab-label, else its own text nodes.const ICON = ':scope > :is(svg, img, i, .tab-icon)';const LUCIDE_NAME = /^[a-z][a-z0-9-]*$/;const iconOf = (tab) => { const icon = tab.querySelector(ICON); if (!icon) return ''; return icon.getAttribute('data-lucide') ?? icon.textContent.trim();};const labelOf = (tab) => { const label = tab.querySelector(':scope > .tab-label'); if (label) return label.textContent.trim(); return Array.from(tab.childNodes) .filter((n) => n.nodeType === Node.TEXT_NODE) .map((n) => n.textContent) .join('') .trim();};const setLabel = (tab, text) => { const label = tab.querySelector(':scope > .tab-label'); if (label) { label.textContent = text; return; } Array.from(tab.childNodes).forEach((n) => { if (n.nodeType === Node.TEXT_NODE) n.remove(); }); tab.append(document.createTextNode(text));};/** A lucide name ("inbox") mounts <i data-lucide> for lucide to render; anything * else (an emoji, a glyph) becomes a <span class="tab-icon">; '' removes it. */const setIcon = (tab, icon) => { tab.querySelector(ICON)?.remove(); if (!icon) return; const el = document.createElement(LUCIDE_NAME.test(icon) ? 'i' : 'span'); el.className = 'tab-icon'; el.setAttribute('aria-hidden', 'true'); if (el.tagName === 'I') el.setAttribute('data-lucide', icon); else el.textContent = icon; tab.prepend(el); if (el.tagName === 'I') globalThis.lucide?.createIcons?.();};const applyContent = (tab, config) => { if (typeof config.label === 'string') setLabel(tab, config.label); if (typeof config.icon === 'string') setIcon(tab, config.icon);};// -- selection ----------------------------------------------------------------const triggersOf = (el) => { const list = el.getAttribute('role') === 'tablist' ? el : el.closest('[role="tablist"]'); return Array.from(list.querySelectorAll('[role="tab"]'));};/** Select one tab of a group and reveal its panel (single-selection model). */const activateTab = (tab, triggers) => { triggers.forEach((t) => { t.setAttribute('aria-selected', 'false'); t.setAttribute('tabindex', '-1'); t.dataset.stateName = t.disabled ? 'disabled' : 'default'; const panel = document.getElementById(t.getAttribute('aria-controls')); if (panel) panel.hidden = true; }); tab.setAttribute('aria-selected', 'true'); tab.removeAttribute('tabindex'); tab.dataset.stateName = 'active'; const panel = document.getElementById(tab.getAttribute('aria-controls')); if (panel) panel.hidden = false;};/** The next enabled tab after `from` (wrapping), or null. */const nextEnabled = (from, triggers) => { const i = triggers.indexOf(from); for (let k = 1; k <= triggers.length; k++) { const c = triggers[(i + k) % triggers.length]; if (!c.disabled && c !== from) return c; } return null;};const tabName = (tab) => tab.disabled ? 'disabled' : tab.getAttribute('aria-selected') === 'true' ? 'active' : 'default';/** Restore one tab to its init snapshot (disabled flag, label, icon). */const restoreTab = (tab) => { tab.disabled = tab._authored.disabled; if (labelOf(tab) !== tab._authored.label) setLabel(tab, tab._authored.label); if (iconOf(tab) !== tab._authored.icon) setIcon(tab, tab._authored.icon); tab.dataset.stateName = tabName(tab);};const authoredTab = (triggers) => triggers.find((t) => t._authored.selected) || triggers.find((t) => !t.disabled);/** * UI side of setState. `el` is a tab trigger or a tablist. Re-entering the * CURRENT state with a config applies just the config (rename / re-icon a * tab without touching the selection); a bare setState(name) always runs the * transition, so setState('default') is a full restore. */function triggerStateChange(el, stateName, config) { const triggers = triggersOf(el); const isList = el.getAttribute('role') === 'tablist'; const reenter = Object.keys(config).length > 0 && tabsApi.getState(el).name === stateName; if (isList) { if (!reenter) { switch (stateName) { case 'default': { triggers.forEach(restoreTab); const tab = authoredTab(triggers); if (tab) activateTab(tab, triggers); break; } case 'active': triggers.forEach((t) => { if (t.disabled && !t._authored.disabled) t.disabled = false; }); break; case 'disabled': triggers.forEach((t) => { t.disabled = true; t.dataset.stateName = 'disabled'; }); break; } } // config.index (or config.id) picks the selected tab const pick = typeof config.index === 'number' ? triggers[config.index] : typeof config.id === 'string' ? triggers.find((t) => t.id === config.id) : null; if (pick && !pick.disabled) activateTab(pick, triggers); return; } const tab = el; if (!reenter) { switch (stateName) { case 'default': { // enabled again; label + icon stay (a rename survives un-disabling) - // the tablist's 'default' is the full as-authored restore const wasSelected = tab.getAttribute('aria-selected') === 'true'; tab.disabled = false; if (tab._authored.selected) activateTab(tab, triggers); else if (wasSelected) { const other = authoredTab(triggers.filter((t) => t !== tab)) || nextEnabled(tab, triggers); if (other && other !== tab) activateTab(other, triggers); else tab.setAttribute('aria-selected', 'false'); } tab.dataset.stateName = tabName(tab); break; } case 'active': if (!tab.disabled) activateTab(tab, triggers); break; case 'disabled': { const wasSelected = tab.getAttribute('aria-selected') === 'true'; tab.disabled = true; tab.dataset.stateName = 'disabled'; // a disabled tab cannot stay selected: hand the selection on if (wasSelected) { const other = nextEnabled(tab, triggers); if (other) activateTab(other, triggers); tab.dataset.stateName = 'disabled'; } break; } } } applyContent(tab, config);}/** Registry-level API; pass a tab trigger or a tablist explicitly. Unknown names throw. */export const tabsApi = { setState(el, stateName, config = {}) { if (!tabsStates.includes(stateName)) { throw new Error(`tabs: unknown state "${stateName}" (supported: ${tabsStates.join(', ')})`); } triggerStateChange(el, stateName, config); // state lives on the ELEMENT, not the module (many tabs per page) el._stateConfig = { ...el._stateConfig, ...config }; }, getState(el) { // reflect reality: clicks/keys change aria-selected without setState() if (el.getAttribute('role') === 'tablist') { const triggers = triggersOf(el); const selected = triggers.findIndex((t) => t.getAttribute('aria-selected') === 'true'); const authored = triggers.findIndex((t) => t._authored?.selected); const name = triggers.every((t) => t.disabled) ? 'disabled' : selected === authored || (authored < 0 && selected <= 0) ? 'default' : 'active'; return { name, config: { ...el._stateConfig, index: selected, id: triggers[selected]?.id ?? '' } }; } return { name: tabName(el), config: { ...el._stateConfig, label: labelOf(el), icon: iconOf(el) }, }; },};df$.tabsApi = tabsApi;df$.tabsStates = tabsStates;function init() {document.querySelectorAll('[role="tablist"]:not([data-init])').forEach((tablist) => { tablist.dataset.init = ''; if (!tablist.querySelector('.tab-trigger')) return; const triggers = Array.from(tablist.querySelectorAll('[role="tab"]')); // remember the authored tab so setState('default') restores it triggers.forEach((t) => { t._authored = { selected: t.getAttribute('aria-selected') === 'true', disabled: t.disabled, label: labelOf(t), icon: iconOf(t), }; // bind-scope the api per tab: `$('#my-tab').api.setState('active')` t.api = { setState: (stateName, config) => tabsApi.setState(t, stateName, config), getState: () => tabsApi.getState(t), }; }); // …and per tablist: `$('#my-tabs').api.setState('active', { index: 2 })` tablist.api = { setState: (stateName, config) => tabsApi.setState(tablist, stateName, config), getState: () => tabsApi.getState(tablist), }; // tabs on the left / right side of the panel are a vertical tablist: // the ARIA orientation (and with it Up/Down keys) follows data-side const side = tablist.closest('.tabs')?.getAttribute('data-side'); if ((side === 'left' || side === 'right') && !tablist.hasAttribute('aria-orientation')) { tablist.setAttribute('aria-orientation', 'vertical'); } // read per key press: the side (and so the orientation) can change after init const vertical = () => { const now = tablist.closest('.tabs')?.getAttribute('data-side'); if (now === 'left' || now === 'right') return true; if (now === 'top' || now === 'bottom') return false; return tablist.getAttribute('aria-orientation') === 'vertical'; }; triggers.forEach((trigger) => { trigger.addEventListener('click', () => { activateTab(trigger, triggers); }); trigger.addEventListener('keydown', (e) => { const current = triggers.indexOf(trigger); let next; const forward = vertical() ? 'ArrowDown' : 'ArrowRight'; const backward = vertical() ? 'ArrowUp' : 'ArrowLeft'; switch (e.key) { case forward: e.preventDefault(); for (let i = 1; i <= triggers.length; i++) { const c = triggers[(current + i) % triggers.length]; if (!c.disabled) { next = c; break; } } break; case backward: e.preventDefault(); for (let i = 1; i <= triggers.length; i++) { const c = triggers[(current - i + triggers.length) % triggers.length]; if (!c.disabled) { next = c; break; } } break; case 'Home': e.preventDefault(); next = triggers.find((t) => !t.disabled); break; case 'End': e.preventDefault(); next = triggers.slice().reverse().find((t) => !t.disabled); break; } if (next && !next.disabled) { activateTab(next, triggers); next.focus(); } }); });});}init();new MutationObserver(init).observe(document, { childList: true, subtree: true });Comments, ideas or improvements? Edit this page's source on GitHub