Published · Updated · v0.6.5 · MIT License · Source: tabs.md
Tabs
Primary · icon + label
Server-rendered panel for the Photos tab. The selected tab is marked by the handler from the current URL — no JavaScript.
Primary · label only
Primary · icon only
Secondary · icon + label
Server-rendered panel for the Activities tab. The selected tab is marked by the handler from the current URL — no JavaScript.
Secondary · label only
Secondary · icon only
Tabs organize groups of related content at the same level of hierarchy. Use tabs when a page holds several peer sections and people should switch between them. Each tab is a real <a href> link to its own page or section, so deep-linking and no-JS navigation work for free. Gelium reimplements the Material 3 tab contract as server-rendered navigation links: there is no component JavaScript, no role="tablist" and no roving focus.
Guidance
When to use
Use tabs when a page holds several peer sections and people should switch between them. Each tab is a real <a href> link to its own page or section, so deep-linking and no-JS navigation work for free.
When not to use
Do not use tabs for a local mode toggle inside a single view — Segmented buttons fit that better. For hierarchical navigation between pages, use a Navigation bar, a Navigation drawer, or plain links instead.
Usability
- Use the same variant for every tab in a bar, and never mix primary and secondary tabs in one bar.
- Primary tabs with both icon and label add
ui-tab-stacked(the icon stacks above the label, 64 px); secondary tabs keep a 48 px row. - The selected tab is decided by the server; clicking a tab is a normal GET navigation that re-renders the page.
Accessibility
- The bar is a
<nav aria-label>with<ul>/<li>/<a>, and the selected tab usesaria-current="page". - No
role="tablist": the full tablist keyboard contract cannot be satisfied without JavaScript, so Gelium does not claim it. Native link keyboard behavior is complete. - Icon-only tabs must carry an
aria-label; icon and indicator are decorative andaria-hidden. - In forced-colors mode the indicator repaints as
CanvasText, selected text becomesHighlight, and the focus ring becomesHighlight.
See Choose the right control for the cross-component decision.
Anatomy
A tab bar is a <nav> wrapping a <ul> of <li> items, each containing an <a class="ui-tab">. The anchor carries the icon (optional), the label (optional), and the active indicator. Keep the order icon, label, indicator exactly.
<nav class="ui-tabs" aria-label="Primary tabs example">
<ul class="ui-tabs-list">
<li class="ui-tabs-item">
<a class="ui-tab ui-tab-primary ui-tab-stacked" href="/components/tabs?tab=photos" aria-current="page">
<span class="ui-tab-icon" aria-hidden="true">…</span>
<span class="ui-tab-label">Photos</span>
<span class="ui-tab-indicator" aria-hidden="true"></span>
</a>
</li>
</ul>
</nav>
- Bar —
ui-tabsis a flex column that fills the width and draws the Material 1px divider below the tabs. Theui-tabs-listaligns items to the bottom edge so mixed-height tabs sit on the same baseline. - Tab —
ui-tabis the real link. Its height is48px(--ui-tab-height), horizontal padding is16px, and the label uses the Materialtitle-smalltypescale (500 .875rem/1.25rem, tracking0.00625rem). The inactive label and icon paint--ui-color-fg-muted(on-surface-variant). - Icon —
ui-tab-iconis a24pxdecorative slot; the visible label (or anaria-labelon icon-only tabs) supplies the accessible name. - Indicator —
ui-tab-indicatoris the3pxactive indicator in--ui-color-primary(primary tabs) or2px(secondary tabs). It is hidden unless the tab is selected. - Variant —
ui-tab-primaryandui-tab-secondaryset the active colors: primary tabs use--ui-color-primaryfor the selected label/icon, secondary tabs use--ui-color-fg. - Stacked — primary tabs with both icon and label add
ui-tab-stacked: the icon stacks above the label (column, gap2px). The tab grows to64px, matching the Materialwith-icon-and-label-text-container-height.
Variants
Material defines two tab variants, and each supports icon-only, label-only, and icon+label content.
- Primary tabs — placed at the top of the content pane. With an icon and label the content stacks vertically (
ui-tab-stacked,64px); label-only and icon-only tabs stay48px. - Secondary tabs — used within a content area. The icon always renders inline with the label (row layout,
48px).
Use the same variant for every tab in a bar, and never mix primary and secondary tabs in one bar.
What states can a tab be in?
The selected tab is decided by the server, so the bar always shows its state without JavaScript:
- Rest — inactive label/icon in on-surface-variant.
- Hover — state layer at
--ui-state-hover-opacity(.08). - Focus-visible — state layer at
--ui-state-focus-opacity(.10) plus the standard--ui-focus-thicknessring in--ui-color-focus-ring. The ring does not change geometry or shift layout. - Active/pressed — state layer at
--ui-state-pressed-opacity(.10). - Selected —
aria-current="page"turns the label/icon to the active color and shows the indicator.
The state layer color follows the Material split: inactive tabs paint on-surface (--ui-color-fg), and selected primary tabs paint primary (--ui-color-primary). Selected secondary tabs keep on-surface.
Selection is server-driven
Each tab is a real link to its own section. On this page the primary tabs link to ?tab=photos|videos|music and the secondary tabs to ?sub=travel|hotel|activities. The handler validates the parameter against a closed vocabulary and marks the matching tab with aria-current="page"; unknown values fall back to the first tab. Clicking a tab is a normal GET navigation — the full page is re-rendered with the new selection and the matching panel content. Nothing requires client-side state.
Accessibility
- Plain navigation semantics — the bar is a
<nav aria-label>with<ul>/<li>/<a>, and the selected tab usesaria-current="page". This is the simplest semantics that satisfies accessibility for the link pattern. - No
role="tablist"— the full tablist keyboard contract (roving tabindex, arrow-key focus, Home/End) cannot be satisfied without JavaScript, so Gelium does not claim it. Native link keyboard behavior (Tab to move, Enter to activate) is complete and announced correctly. - No roving focus — arrow-key roving focus would require a demonstrated platform gap. The roadmap only allows it when the gap is real, and for real navigation links there is none.
- Icon-only tabs must carry an
aria-label; icon and indicator are decorative andaria-hidden. - In forced-colors mode the indicator repaints as
CanvasText, selected text becomesHighlight, and the focus ring becomesHighlight, so selection survives without color.
How does keyboard navigation work?
Because every tab is a real link, keyboard use is native: Tab moves between tabs, and Enter activates the focused tab. Browser conventions (focus outline, current-page announcement) apply unchanged.
Progressive enhancement
There is no enhancement layer: the server-rendered link flow is the complete flow. HTMX (the progressive-enhancement library that swaps server-rendered fragments) panel swapping could be added later. It is not required — the fallback already navigates normally with plain links.