Tabs
hc-tabs ships two markup patterns under the same classnames and visual
style:
| Pattern | When | Markup | Needs JS? |
|---|---|---|---|
| App-state | A single page swaps panels without changing the URL. | <div role="tablist"> + <button role="tab"> + <div role="tabpanel"> | Yes — installTabs() wires roving tabindex, arrow keys, and panel toggling. |
| URL-routed | Each tab is its own URL or query parameter. | <nav> + <a href> with aria-current="page" | No — the browser’s native link semantics already handle keyboard and focus. |
The app-state pattern follows the WAI-ARIA APG Tabs pattern and uses manual activation by default so panels can lazy-load over htmx without firing a request on every arrow key.
App-state tabs
Section titled “App-state tabs”General account settings.
Billing details.
Team members.
<div class="hc-tabs"> <div class="hc-tabs__list" role="tablist" aria-label="Account"> <button type="button" class="hc-tabs__tab" role="tab" id="tab-general" aria-controls="panel-general" aria-selected="true" tabindex="0">General</button> <button type="button" class="hc-tabs__tab" role="tab" id="tab-billing" aria-controls="panel-billing" aria-selected="false" tabindex="-1">Billing</button> </div>
<div class="hc-tabs__panel" role="tabpanel" id="panel-general" aria-labelledby="tab-general" tabindex="0"> General settings panel. </div> <div class="hc-tabs__panel" role="tabpanel" id="panel-billing" aria-labelledby="tab-billing" tabindex="0" hidden="until-found"> Billing details panel. </div></div>import { installTabs } from '@hypermedia-components/core';installTabs();Activation mode
Section titled “Activation mode”By default the behavior uses manual activation — arrow keys move
focus, and the user confirms with Enter or Space. This is the
right default when panels lazy-load (htmx, IntersectionObserver) since
focus alone does not trigger a request.
To activate panels as soon as the tab receives focus, opt in with
data-activation="automatic":
<div class="hc-tabs" data-activation="automatic">…</div>Variants
Section titled “Variants”<div class="hc-tabs" data-variant="pill">…</div>data-variant | Style |
|---|---|
default | Underline indicator on the active tab. |
pill | Filled background on the active tab; the list drops its baseline. |
data-size accepts sm, md (default), and lg. The default size
also follows data-density, so dense layouts shrink without per-tab
work.
<div class="hc-tabs" data-size="sm">…</div>Overflow (scrollable)
Section titled “Overflow (scrollable)”By default a tab list wraps to a second line when it runs out of room.
Add data-overflow="scroll" to keep it on one horizontally-scrollable row
instead:
<div class="hc-tabs" data-overflow="scroll"> <div class="hc-tabs__list" role="tablist" aria-label="Sections"> <button class="hc-tabs__tab" role="tab" …>Overview</button> <!-- …many tabs… --> </div> <!-- panels --></div>installTabs() does the rest:
- It injects edge scroll buttons (a mouse affordance) that appear only when there is more to scroll in that direction. They sit outside the tab order — keyboard users rely on the arrow keys, which already scroll.
- The active and focused tabs are kept in view: arrow-key navigation, activation, and the initially-selected tab all scroll into the visible row.
- Touch / trackpad scroll works natively; the scrollbar is hidden.
- It is direction-aware — in RTL the buttons flip to the other edge and the chevrons mirror.
The scroll buttons are decorative (aria-hidden), so this is purely additive
to the accessible tab pattern.
Vertical orientation
Section titled “Vertical orientation”Add data-orientation="vertical" to the root to stand the tab list up as a
column beside its panels. installTabs() reflects this onto the tablist’s
aria-orientation="vertical", which flips the arrow-key axis: ↑ /
↓ move between tabs (instead of ← / →), while
Home / End and activation are unchanged. The active
indicator moves from the underline to an inline-start bar (logical, so it
flips in RTL).
<div class="hc-tabs" data-orientation="vertical"> <div class="hc-tabs__list" role="tablist" aria-orientation="vertical" aria-label="Workspace"> <button class="hc-tabs__tab" role="tab" …>Overview</button> <!-- … --> </div> <!-- panels --></div>installTabs() sets aria-orientation for you, but include it in the markup
too so the orientation is correct before JavaScript runs. Vertical and the
scrollable overflow row are separate layouts — don’t combine them.
URL-routed tabs
Section titled “URL-routed tabs”When each tab is its own route, drop the ARIA tab roles and let the
browser treat the markup as ordinary navigation. aria-current="page"
marks the active tab; installTabs() ignores this variant.
<div class="hc-tabs"> <nav class="hc-tabs__list" aria-label="Documentation"> <a class="hc-tabs__tab" href="/docs/overview" aria-current="page">Overview</a> <a class="hc-tabs__tab" href="/docs/api">API</a> <a class="hc-tabs__tab" href="/docs/changelog">Changelog</a> </nav></div>htmx usage
Section titled “htmx usage”A panel can fetch its content the first time it becomes active by
listening for hc:tabactivated — the event the behavior dispatches
when a panel is revealed.
<div class="hc-tabs__panel" role="tabpanel" id="panel-billing" aria-labelledby="tab-billing" tabindex="0" hidden="until-found" data-hx-get="/account/billing" data-hx-trigger="hc:tabactivated once" data-hx-target="this" data-hx-swap="innerHTML"> <hc-spinner></hc-spinner></div>Find-in-page
Section titled “Find-in-page”Inactive panels carry hidden="until-found" so the browser’s Ctrl+F
can search them. When the user’s query matches text inside a hidden
panel the browser fires beforematch; the behavior catches it and
auto-switches to the owning tab so the match is visible. Browsers
without hidden="until-found"
treat the attribute as a plain hidden.
Accessibility
Section titled “Accessibility”- The app-state pattern follows the WAI-ARIA APG. Roles:
tablist,tab,tabpanel. - Each tab needs
aria-controlspointing at its panel; each panel needsaria-labelledbypointing back at its tab. - The active tab carries
aria-selected="true"andtabindex="0"; inactive tabs carryaria-selected="false"andtabindex="-1"(roving tabindex pattern). Disabled tabs usearia-disabled="true"and are skipped by arrow navigation. - The tablist must have an accessible name. Use
aria-labeloraria-labelledbyon the[role="tablist"]element. - A vertical tablist carries
aria-orientation="vertical"(set byinstallTabs()fromdata-orientation); the arrow-key axis follows it — ↑ / ↓ when vertical, ← / → when horizontal. - Panels are programmatically focusable (
tabindex="0") so keyboard users canTabinto their content directly. - The URL-routed variant inherits all navigation accessibility from
<nav>+<a>. Do not add ARIA tab roles to it — that would conflict with the navigation pattern.
Theming tokens
Section titled “Theming tokens”Component tokens (in component.tokens.json):
| Token path | Purpose |
|---|---|
tabs.list.gap / list.border | Gap between tabs and bottom border. |
tabs.tab.height / padding-x | Inherits --hc-control-* (density). |
tabs.tab.fg / hover-fg / hover-bg / active-fg | Tab text and hover surface. |
tabs.tab.indicator / indicator-size | Active-state underline. |
tabs.tab.disabled-fg | Tab text when aria-disabled="true". |
tabs.pill.active-bg / active-fg | Filled background for data-variant="pill". |
tabs.panel.padding-y / focus-ring | Panel breathing room and focus outline. |
tabs.sm.* / tabs.lg.* | Dedicated sm / lg overrides. |
CSS variables
Section titled “CSS variables”Show the generated CSS variables
--hc-tabs-list-gap | -list-border--hc-tabs-tab-height | -padding-x | -font-size | -font-weight | -radius--hc-tabs-tab-fg | -hover-fg | -hover-bg | -active-fg--hc-tabs-tab-indicator | -indicator-size--hc-tabs-tab-disabled-fg--hc-tabs-pill-active-bg | -pill-active-fg--hc-tabs-panel-padding-y | -panel-focus-ring--hc-tabs-scroll-size /* width of the overflow scroll buttons; default 2rem */--hc-tabs-sm-tab-height | -sm-tab-padding-x | -sm-tab-font-size--hc-tabs-lg-tab-height | -lg-tab-padding-x | -lg-tab-font-size--hc-control-height (inherited from data-density)--hc-color-action-primary-bg | -primary-fg (inherited from data-color)--hc-color-focus-ring