Tabs
The custom element is <hui-tabs>.
Overview
A single tab stop with roving tabindex, arrow movement, Home and End, and the choice of automatic or manual activation. Tabs and panels are light-DOM children the Go host rendered, so HTMX can swap panel content in place. With navigation it becomes a landmark of real links whose current one is the server's aria-current="page".
Example
Source
<hui-tabs value="one"> <button slot="tab" role="tab" data-value="one">One</button> <button slot="tab" role="tab" data-value="two">Two</button> <div role="tabpanel" data-value="one">First panel</div> <div role="tabpanel" data-value="two">Second panel</div></hui-tabs>The Go template that renders it:
<hui-tabs value="{{ .Tab }}"> {{ range .Tabs }} <button slot="tab" role="tab" data-value="{{ .ID }}">{{ .Label }}</button> {{ end }} {{ range .Tabs }} <div role="tabpanel" data-value="{{ .ID }}">{{ .Body }}</div> {{ end }}</hui-tabs>On a phone
Tabs that do not fit scroll their own row, with its scrollbar hidden, and the selected tab is scrolled into view on load and whenever it changes. The page does not scroll sideways.
Source
<hui-tabs value="notifications"> <button slot="tab" role="tab" data-value="overview">Overview</button> <button slot="tab" role="tab" data-value="analytics">Analytics</button> <button slot="tab" role="tab" data-value="reports">Reports</button> <button slot="tab" role="tab" data-value="notifications">Notifications</button> <button slot="tab" role="tab" data-value="settings">Settings</button> <div role="tabpanel" data-value="overview">Overview panel</div> <div role="tabpanel" data-value="analytics">Analytics panel</div> <div role="tabpanel" data-value="reports">Reports panel</div> <div role="tabpanel" data-value="notifications">Notifications panel</div> <div role="tabpanel" data-value="settings">Settings panel</div></hui-tabs>API
Attributes5
| Name | Type | Default | Description |
|---|---|---|---|
value | string | "" | The data-value (or value) of the selected tab. Reflected, and defaults to the first tab when unset or unknown. |
orientation | "horizontal" | "vertical" | "horizontal" | The tablist axis, which decides whether Left/Right or Up/Down move focus. |
activation | "automatic" | "manual" | "automatic" | automatic selects as focus moves; manual moves focus only and commits on Enter or Space. |
variant | "default" | "line" | "default" | The list's look. default is the segmented list on a muted track. line is a bare row with no track and no raised chip, the active tab in full foreground under a 2px underline. |
navigation | boolean | false | Makes the row a role="navigation" landmark of real links rather than a tablist. The element owns no panels, reads aria-current="page" but never writes it, and leaves activation to the link. |
Properties5
| Name | Type | Default | Description |
|---|---|---|---|
value | string | '' | Reflects to the value attribute. |
orientation | TabsOrientation | 'horizontal' | Reflects to the orientation attribute. |
activation | TabsActivation | 'automatic' | Reflects to the activation attribute. |
variant | TabsVariant | 'default' | Reflects to the variant attribute. |
navigation | boolean | false | Reflects to the navigation attribute. |
Events4
| Name | Description |
|---|---|
hui-open | A new tab was selected. It bubbles and is composed, with detail { value } for the new value. |
hui-close | The previous tab was deselected. It bubbles and is composed, with detail { value } for the previous value. |
change | A native change event that bubbles and is composed, so Alpine x-model binds. |
click on [slot="tab"] | Clicking a tab selects its value. In navigation mode there is nothing to select: the link follows itself and none of these fire. |
Slots2
| Name | Description |
|---|---|
tab | The role="tab" controls that make up the tablist, or the links in navigation mode. |
(default) | The role="tabpanel" panels. Unused in navigation mode. |
CSS custom properties5
| Name | Default | Description |
|---|---|---|
--hui-muted | oklch(0.96 0.003 325.6) | Tablist background. |
--hui-muted-foreground | oklch(0.542 0.034 322.5) | Inactive tab text. |
--hui-background | oklch(1 0 0) | Selected tab background. |
--hui-foreground | oklch(0.145 0.008 326) | Selected tab text. |
--hui-radius-md | calc(var(--hui-radius, 0.45rem) * 0.8) | Tablist corner radius. |
::part() hooks1
| Name | Description |
|---|---|
tablist | The internal wrapper. It carries role="tablist", or role="navigation" in navigation mode. |
States
Line
variant="line" drops the track and the raised chip: a bare row, the active tab in full foreground under a 2px underline. It suits tabs nested inside a card, where a second segmented track competes with the first.
Source
<hui-tabs variant="line" value="https"> <button slot="tab" role="tab" data-value="https">HTTPS</button> <button slot="tab" role="tab" data-value="ssh">SSH</button> <button slot="tab" role="tab" data-value="cli">GitHub CLI</button> <div role="tabpanel" data-value="https">Clone using the web URL.</div> <div role="tabpanel" data-value="ssh">Use a password-protected SSH key.</div> <div role="tabpanel" data-value="cli">Work fast with the official CLI.</div></hui-tabs>Vertical, manual activation
orientation="vertical" moves with Up and Down. activation="manual" separates moving from choosing: the arrows move focus, and Enter or Space switches the panel.
Source
<hui-tabs orientation="vertical" activation="manual" value="a"> <button slot="tab" role="tab" data-value="a">Alpha</button> <button slot="tab" role="tab" data-value="b">Beta</button> <div role="tabpanel" data-value="a">Alpha panel</div> <div role="tabpanel" data-value="b">Beta panel</div></hui-tabs>Server navigation
With navigation the row is a role="navigation" landmark of real links, not a tablist. The server marks the current link with aria-current="page"; the element owns no panels and writes no aria-selected. The arrows move focus and Enter follows the link, so a host swaps a whole workflow rather than one panel. This is UI-CONTRACT-007: the markup is the state. A Go template emits it directly: {{ range .Steps }}<a slot="tab" href="{{ .URL }}"{{ if .Current }} aria-current="page"{{ end }}>{{ .Label }}</a>{{ end }}.
Source
<hui-tabs navigation variant="line" aria-label="Workflow"> <a slot="tab" href="/overview" aria-current="page">Overview</a> <a slot="tab" href="/runs">Runs</a> <a slot="tab" href="/settings">Settings</a></hui-tabs>Server-side mechanics
The server owns the tabs and panels: role="tab" children in slot="tab" and role="tabpanel" children in the default slot, each carrying data-value (or value), plus value, orientation and activation. Render <hui-tabs value="two"> to open a given tab, and read value back because it reflects; an unknown or missing value falls back to the first tab. It is not form-associated and submits nothing, though selecting a tab emits bubbling, composed hui-open and hui-close events plus a native change for Alpine x-model. Panels are light-DOM children, so an hx-swap can replace a panel's contents in place, with the ids and aria-controls/aria-labelledby re-synced on the slot's change event. A swap that replaces the whole hui-tabs element needs the server to emit value again, because the component keeps no state of its own.
Accessibility
- The internal wrapper carries
role="tablist"andaria-orientation. - Each tab gets
aria-selectedandaria-controls; each panel getsaria-labelledby; ids are generated when absent. - Inactive panels are
hiddenand markeddata-state="inactive". - Roving tabindex keeps the tablist a single tab stop, with exactly one tab carrying
tabindex="0". - In navigation mode the wrapper is a
role="navigation"landmark with the host'saria-label; a row of links is not a tablist, so notabortabpanelroles are invented and the current link is the server'saria-current="page".
Keyboard
| Keys | Action |
|---|---|
Arrow Left, Arrow Right | Horizontal orientation: move focus between tabs, wrapping. With automatic activation the panel follows focus. |
Arrow Up, Arrow Down | Vertical orientation: move focus between tabs, wrapping. |
Home, End | Move focus to the first or last tab. |
Enter, Space | In manual activation, commit the focused tab without waiting for the next arrow press. |
Enter (navigation mode) | Follows the focused link, natively, as the browser does for any anchor. The arrows move focus and the tab stop without changing the server's aria-current. |
Gotchas
- Selection is reported with the event names
hui-openandhui-close, nothui-selectorchangealone, so a listener expecting a value-carrying event will miss it.