Skip to content

<u-tabs> ​

<u-tabs> is not a native HTML element, but follows ARIA authoring practices for tabs. It lets you navigate between groups of information that appear in the same context.

Quick intro:

  • Use <u-tabs> to group all tabbing-elements
  • Use <u-tablist> around multiple <u-tab> direct children - these are the labels
  • Use <u-tab aria-selected="true"> to set the open tab (defaults to first non-disabled tab, unless any tab has aria-selected)
  • Use <u-tab aria-controls="id-of-panel"> if you need panels outside <u-tabs>
  • Use <u-tabpanel>s after <u-tablist> - these hide/show content of related <u-tab>
  • ARIA Authoring Practices Guide Docs: Tabs

Example ​

Install ​

bash
npm add -S @u-elements/u-tabs
bash
pnpm add -S @u-elements/u-tabs
bash
yarn add @u-elements/u-tabs
bash
bun add -S @u-elements/u-tabs
html
<script type="module" src="https://unpkg.com/@u-elements/u-tabs@latest/dist/u-tabs.js"></script>

Attributes and props ​

<u-tabs> ​

  • Attributes: all global HTML attributes such as id, class, data-
  • DOM interface: UHTMLTabsElement extends HTMLElement
    • UHTMLTabsElement.tabList returns the contained UHTMLTabListElement
    • UHTMLTabsElement.selectedIndex sets or gets a number reflecting the index of the first selected <u-tab> element. Will ignore invalid indexes. Setting it before the tabs exist is ignored like on a native <select>, so when rendering from state, use aria-selected="true" on the <u-tab> instead.
    • UHTMLTabsElement.tabs returns a NodeListOf<UHTMLTabElement>
    • UHTMLTabsElement.panels returns a NodeListOf<UHTMLTabPanelElement>

<u-tablist> ​

  • Attributes: all global HTML attributes such as id, class, data-
  • DOM interface: UHTMLTabListElement extends HTMLElement
    • UHTMLTabListElement.tabsElement returns the parent UHTMLTabsElement
    • UHTMLTabListElement.selectedIndex sets or gets a number reflecting the index of the first selected <u-tab> element. Will ignore invalid indexes. Setting it before the tabs exist is ignored like on a native <select>, so when rendering from state, use aria-selected="true" on the <u-tab> instead.
    • UHTMLTabListElement.tabs returns a NodeListOf<UHTMLTabElement>

<u-tab> ​

  • Attributes: all global HTML attributes such as id, class, data-
    • aria-selected can contain "true" or "false" to set the currently selected tab. If no tab has aria-selected, the first non-disabled tab is selected automatically.
    • aria-controls can contain ID the <u-tabpanel> to control
  • DOM interface: UHTMLTabElement extends HTMLElement
    • UHTMLTabElement.tabsElement returns the parent UHTMLTabsElement
    • UHTMLTabElement.selected sets or gets true or false, indicating whether this tab is currently selected
    • UHTMLTabElement.index returns a number representing the position/index within the list of tabs
    • UHTMLTabElement.panel returns the associated UHTMLTabPanelElement

<u-tabpanel> ​

  • Attributes: all global HTML attributes such as id, class, data-
  • DOM interface: UHTMLTabPanelElement extends HTMLElement
    • UHTMLTabPanelElement.tabsElement returns the parent UHTMLTabsElement
    • UHTMLTabPanelElement.tabs returns a associated NodeListOf<UHTMLTabElement>

Events ​

No custom events beyond the usual events supported by HTML elements. Tabbing triggers a click (both with mouse and keyboard) so listen for this to check for tab change:

js
document.addEventListener('click', ({ target }) => {
  const tab = target instanceof Element && target.closest('u-tab');

  if (tab) {
    console.log('changed to tab:', tab);
  }
})

Important:

If you're using a framework that delegates click events, call event.preventDefault() during the capture phase to prevent the tab from changing. For example, use onClickCapture in React and Solid, capture:click in Qwik, and onclickcapture in Svelte.

Styling ​

<u-tabs>, <u-tablist> and <u-tabpanel> renders as display: block, while <u-tab> renders as display: inline-block.

Styling the tab state ​

<u-tab> automatically gets a aria-selected="true" or aria-selected="false" attribute, which can be utilized for styling:

css
.my-tab[aria-selected="true"] {
  /* Your active tab styling here */
}
.my-tab[aria-selected="false"] {
  /* Your inactive tab styling here */
}

Styling example: Scrolling tablist ​

Styling example: Wrapping tablist ​

Server side rendering ​

You can server side render <u-tabs> by using Declarative Shadow DOM. Example:

TS
import { UHTMLTabsShadowRoot, UHTMLTabListShadowRoot, UHTMLTabShadowRoot, UHTMLTabPanelShadowRoot } from '@u-elements/u-tabs';

const renderToStaticMarkup = (tabs: string[], panels[]) =>
 `<u-tabs>
   ${UHTMLTabsShadowRoot}
   <u-tablist>
     ${UHTMLTabListShadowRoot}
     ${tabs.map((tab: string) =>
       `<u-tab>${UHTMLTabShadowRoot}${tab}</u-tab>`
     ).join('')}
   </u-tablist>
   ${panels.map((panel: string) =>
     `<u-tabpanel>${UHTMLTabPanelShadowRoot}${panel}</u-tabpanel>`
   ).join('')}
 </u-tabs>`

Hydration: Attributes are dynamically added in the browser, so the client DOM will differ from the server markup. Use suppressHydrationWarning in React, data-allow-mismatch="attribute" in Vue, or similar for your framework on these elements, to avoid hydration warnings.

Accessibility ​

Screen reader <u-tabs>
VoiceOver (Mac) + Chrome ✅
VoiceOver (Mac) + Edge ✅
VoiceOver (Mac) + Firefox ✅
VoiceOver (Mac) + Safari ✅
VoiceOver (iOS) + Safari ✅
Jaws (PC) + Chrome ✅
Jaws (PC) + Edge ✅
Jaws (PC) + Firefox ✅
NVDA (PC) + Chrome ✅
NVDA (PC) + Edge ✅
NVDA (PC) + Firefox ✅
Narrator (PC) + Chrome ✅
Narrator (PC) + Edge ✅
Narrator (PC) + Firefox ✅
TalkBack (Android) + Chrome ✅
TalkBack (Android) + Firefox ✅
TalkBack (Android) + Samsung Internet ✅

Specifications ​

Changelog ​

  • 2.0.0:
    • The first enabled tab is only selected by default when no tab has aria-selected. If any tab has aria-selected and none is "true" (i.e. aria-selected="false" on a single tab), no tab is selected and no panel is shown
    • aria-disabled="true" only blocks user selection (click and keyboard). aria-selected, selected and selectedIndex can select a disabled tab, like a disabled <option>, so only one tab is ever selected
    • selectedIndex can be read and written before the element is connected, as frameworks set properties on a detached tree (i.e. React and Vue)
    • <u-tabpanel> re-associates with its <u-tab> when id is written after connect (i.e. Angular binds attributes after inserting the element)
    • Tabs, tablists and panels are also matched by instance, so custom tag names extending the classes work before connect
    • Framework typings for Qwik 1, Qwik 2 and Preact 11 uses HTMLElementTagNameMap
  • 1.0.4: Allow no tab to be selected for better React async render compatibility
  • 1.0.3: Fix issue where aria-controls combined with aria-selected was not repspected on initial mount
  • 1.0.2: Performance optimizations
  • 1.0.1: Instant attribute changes for easier testing
  • 1.0.0: Plays nice with strict CSP, and renders attributes in consistent order
  • 0.1.2: Enhanced React compatibility by removing u-tabs unknown attribute warning
  • 0.1.1: Enhanced performance by not relying on UTabsElement MutationObserver
  • 0.1.0: Allows changing tag name when extending CustomElement class
  • 0.0.12: Respect aria-disabled="true"
  • 0.0.11: Enable declarative shadow root support and export UHTMLTabsShadowRoot, UHTMLTabListShadowRoot, UHTMLTabShadowRoot and UHTMLTabPanelShadowRoot for easier server side rendering
  • 0.0.10: Automatically adds aria-hidden="true" when hidden to prevent Safari 18.6 from announcing hidden panels

Released under the MIT License