Skip to content

HabTabs ​

A horizontal view switch, with a 2px accent underline. Docks to the bottom edge of a page header.

The overview panel.

A view switch — the panel swaps in place
vue
<HabTabs v-model="view" :items="items" label="Workspace views">
	<template #panel="{ item }">
		<p>The {{ item.label }} panel.</p>
	</template>
</HabTabs>

Tabs, or navigation? ​

A role="tab" carries aria-controls pointing at its panel. So a tab strip with no panels wires every tab to an element that does not exist — and the component warns when you build one.

The warning is really a design question. If the content lives elsewhere — another route, a region further down the page — then the control is navigation, not tabs. ARIA draws the line in the same place. Use HabSubNav or a set of links.

Against HabAccordion: tabs when the sections are peers and only one can be relevant at a time. An accordion when the reader may want two open.

The underline ​

It lives on the active trigger, as a border whose color changes while its width does not — every trigger reserves the 2px, so switching tabs cannot reflow the strip.

reka ships a TabsIndicator that measures the active tab and can slide the line between tabs. It is deliberately unused. It reads offsetWidth and offsetLeft, so it renders nothing at all without layout: its classes would never be emitted in a test run, npm run check:classes could not protect them, and a renamed token there would silently leave the strip with no underline — a failure this repo has shipped three times. The design contract asks for a 2px accent underline and never for a sliding one.

activation ​

automatic — the default — switches tab as focus moves with the arrow keys. Use manual when a panel is expensive to render, so arrowing across five tabs does not mount five panels.

fill ​

By default each trigger takes its own content width and the strip sits left-aligned. fill makes the triggers share the strip equally instead.

The activities panel.

The activities panel.

The same two tabs in the same 320px column — left-aligned by default, sharing the strip with `fill`
vue
<HabTabs v-model="tab" :items="items" label="Inspector" fill>
	<template #panel="{ item }">…</template>
</HabTabs>

Opt-in, because left-aligned is right for the usual case. Five tabs stretched across a page header leave every label stranded in its own acre. fill is for the other shape: a strip that is narrow and a set of tabs that is small — two tabs in a 300px inspector panel, where left-aligned leaves most of the strip empty.

Only while they fit. A trigger never shrinks below its own label, so the strip scrolls once the labels outgrow it — the same thing it does without fill. There is no point at which the tabs squeeze.

keepMounted ​

A tab switch unmounts the panel it leaves, which throws away whatever that panel was holding: a search box's text, a scroll position, a half-filled form. keepMounted renders every panel and hides the inactive ones instead.

Type in each, switch tabs and come back — only the kept panel still has it
vue
<HabTabs v-model="tab" :items="items" label="Inspector" keep-mounted>
	<template #panel="{ item }">
		<!-- this panel's own state survives a switch away and back -->
	</template>
</HabTabs>

It is not the default, because it is not free. Every panel renders on first paint, so a panel that fetches in onMounted fetches for every tab at once — the cost activation: "manual" exists to avoid, approached from the other side. The caller is the one who knows whether their panels hold state worth keeping.

It is not forceMount. reka exposes that on each panel, and it would render them all and show them all at once: the panel's hidden attribute comes from forceMount || isSelected. What Habitus forwards is unmountOnHide on the root, which separates the two questions — hidden goes on tracking the selection while the slot renders regardless. The distinction matters if you ever reach for the primitive directly.

The alternative is worse. A caller whose panels hold state would otherwise render them outside HabTabs behind v-show and drop the panel slot — which is the case the no-panel warning is about, and it ships tabs whose aria-controls points at nothing.

scroll ​

By default the tabs and their panel scroll with whatever they sit in. scroll keeps the strip in place and makes the panel the scroller, for a page whose tabs switch between long lists — without it the strip scrolls away with the list, and switching tab means scrolling back up first.

Views — sticky

  • Row 1
  • Row 2
  • Row 3
  • Row 4
  • Row 5
  • Row 6
  • Row 7
  • Row 8
  • Row 9
  • Row 10
  • Row 11
  • Row 12
  • Row 13
  • Row 14
  • Row 15
  • Row 16
  • Row 17
  • Row 18
  • Row 19
  • Row 20
  • Row 21
  • Row 22
  • Row 23
  • Row 24
  • Row 25
  • Row 26
  • Row 27
  • Row 28
  • Row 29
  • Row 30
A 280px box: the strip stays, the panel scrolls, and a sticky row pins under the strip
vue
<div class="flex h-full flex-col">
	<HabTabs v-model="tab" :items="items" label="Model" scroll class="min-h-0 flex-1">
		<template #panel="{ item }">…</template>
	</HabTabs>
</div>

The tabs fill the height they are given. The root becomes a flex column, so lay it out like any flex child: flex-1 min-h-0 in a column. A host that gives it no height gets a panel that never needs to scroll.

A sticky child pins flush under the strip. The panel's top spacing sits inside the scroll, on a wrapper around the content, instead of padding the scroller. Padding on a scroller is the edge position: sticky pins to, so a toolbar stuck at the top would otherwise sit that padding's height below the strip with the content showing through above it.

The panel stays full-bleed. Its content carries its own horizontal inset, as a flush body's rows do, so a sticky toolbar's divider can run edge to edge.

--hab-tabs-inset lines the tabs up with it. Set on the root, it insets the strip's tabs while its line still runs edge to edge — the page's gutter, in the page's own units and breakpoints:

vue
<HabTabs scroll class="min-h-0 flex-1 [--hab-tabs-inset:--spacing(8)]" …>

Unset it is 0, so a strip without it is exactly what it was. It is a custom property rather than a prop because the value is the host's — no token in the theme names a page gutter.

Each tab keeps its place with keepMounted. Every panel is its own scroller, so with keepMounted switching away from a long list and back returns to where the reader was. Without it the panel is unmounted on switch and starts at the top again.

Off by default, because most tabs sit in a page or a panel that already scrolls, and a second scroller inside that one is a trap.

HabSubNav ​

Vertical section nav on a light surface — settings, wizards.

Vertical section nav — a landmark, not a tab strip
vue
<HabSubNav v-model="section" :items="items" label="Settings sections" />

It is a nav landmark, not a tab strip: the rows point at sections of a larger thing rather than swapping one panel in place, so they carry aria-current instead of role="tab".

HabNavItem will be the dark-rail equivalent. Do not use that one on a page.

Accessibility ​

  • Each tab is wired to its panel by the primitive, with arrow-key navigation and roving tabindex.
  • label names the strip, and the component warns without one.
  • HabSubNav needs its own label too: two navs on one page are indistinguishable to anyone listing the page's landmarks.
  • A disabled tab is disabled natively and skipped by arrow navigation.
  • With keepMounted the inactive panels stay in the DOM but keep their hidden attribute, so they are out of the accessibility tree and out of the tab order — one panel is exposed at a time, exactly as without it.
  • fill changes no role, name or relationship. It is a width strategy on the triggers and nothing else.

API ​

Props ​

PropTypeDefaultDescription
activation"automatic" | "manual""automatic"Whether moving focus with the arrow keys also switches tab. automatic is the default and the right choice when switching is cheap. Use manual when a panel is expensive to render, so arrowing across five tabs does not mount five panels.
fillbooleanfalseTriggers share the strip's width equally, instead of each taking its own content width and the strip sitting left-aligned. OPT-IN, because left-aligned is right for the usual case: five tabs stretched across a page header leave every label stranded in its own acre. Turn it on where the strip is narrow and the tabs are few — a two-tab switch in a 300px inspector panel, where left-aligned leaves most of the strip empty. Only while they fit. A trigger still never shrinks below its own label, so once the labels outgrow the strip it scrolls, exactly as it does without this.
itemsHabTabItem[][]—
keepMountedbooleanfalseRender every panel and hide the inactive ones, instead of unmounting them. The default unmounts, which throws away whatever the panel was holding — a search box's text, a scroll position, a half-filled form. Set this where a panel owns state the reader would be annoyed to lose on a tab switch. It is not free: every panel renders on first paint, so a panel that fetches in onMounted fetches for every tab at once. That is the cost activation: "manual" exists to avoid, approached from the other side — and it is why this is opt-in rather than the default.
labelstringundefinedAccessible name for the tab strip.
modelValuestringundefinedThe active tab's id. Bind with v-model.
scrollbooleanfalseThe strip stays put and the panel scrolls beneath it. The tabs fill the height their parent gives them — the root becomes a flex column, so a host lays it out like any other flex child (flex-1 min-h-0 in a column) — and the active panel is the scroller. For a page whose tabs switch between long lists: without it the strip scrolls away with the list, and switching tab means scrolling back up first. The panel's top spacing scrolls with its content rather than padding the scroller, so a position: sticky child pins flush under the strip instead of a padding's height below it. The panel stays full-bleed: its content carries its own horizontal inset, so a sticky toolbar's divider can run edge to edge. Pair it with --hab-tabs-inset on the root to line the tabs up with that content while the strip's line reaches the edges too. Each panel is its own scroller, so with keepMounted every tab keeps its scroll position; without it, a panel switched away from and back starts at the top again. Off by default, because most tabs sit in a page or a panel that scrolls already, and a second scroller inside that one is a trap.

Events ​

EventPayloadDescription
update:modelValuevalue: string—

Slots ​

SlotBindingsDescription
panel{ item: HabTabItem }The panel for each tab. Receives the item.

HabSubNav ​

Props ​

PropTypeDefaultDescription
itemsHabSubNavItem[][]—
labelstringundefinedAccessible name for the nav. Two navs on a page need distinct names.
modelValuestringundefinedThe current section's id. Bind with v-model.

Events ​

EventPayloadDescription
update:modelValuevalue: string—

Proprietary — internal use only.