Skip to content

Tabs

hc-tabs ships two markup patterns under the same classnames and visual style:

PatternWhenMarkupNeeds JS?
App-stateA 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-routedEach 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.

General account settings.

import { installTabs } from '@hypermedia-components/core';
installTabs();

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>
Day view.
data-variantStyle
defaultUnderline indicator on the active tab.
pillFilled 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>

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.

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).

Overview panel.

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.

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.

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>

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.

  • The app-state pattern follows the WAI-ARIA APG. Roles: tablist, tab, tabpanel.
  • Each tab needs aria-controls pointing at its panel; each panel needs aria-labelledby pointing back at its tab.
  • The active tab carries aria-selected="true" and tabindex="0"; inactive tabs carry aria-selected="false" and tabindex="-1" (roving tabindex pattern). Disabled tabs use aria-disabled="true" and are skipped by arrow navigation.
  • The tablist must have an accessible name. Use aria-label or aria-labelledby on the [role="tablist"] element.
  • A vertical tablist carries aria-orientation="vertical" (set by installTabs() from data-orientation); the arrow-key axis follows it — / when vertical, / when horizontal.
  • Panels are programmatically focusable (tabindex="0") so keyboard users can Tab into 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.

Component tokens (in component.tokens.json):

Token pathPurpose
tabs.list.gap / list.borderGap between tabs and bottom border.
tabs.tab.height / padding-xInherits --hc-control-* (density).
tabs.tab.fg / hover-fg / hover-bg / active-fgTab text and hover surface.
tabs.tab.indicator / indicator-sizeActive-state underline.
tabs.tab.disabled-fgTab text when aria-disabled="true".
tabs.pill.active-bg / active-fgFilled background for data-variant="pill".
tabs.panel.padding-y / focus-ringPanel breathing room and focus outline.
tabs.sm.* / tabs.lg.*Dedicated sm / lg overrides.
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
  • Dialog — modal alternative for grouping content that should not stay visible.
  • Popover — transient sibling.
  • Toolbar — horizontal action cluster (often paired with tabs).