Skip to content

HabNavDrawer ​

The full navigation rail: a brand row with a collapse toggle, grouped items, a footer.

Collapse it with the brand button — nothing unmounts, so focus stays put
vue
<HabNavDrawer
	v-model="current"
	v-model:collapsed="collapsed"
	:items="items"
	label="Main navigation"
	brand="Alternis"
>
	<template #footer="{ collapsed }">
		<HabAvatar
			name="Ada Lovelace"
			size="sm"
			decorative
		/>
	</template>
</HabNavDrawer>

It needs a --hab-nav surface, which it provides. Whether a product has a rail at all is a product decision — these are just the parts.

The rail is 232px open and its rows are full --hab-row-h, not the compact step: a rail row is a primary navigation target hit with a pointer all day, not a dense table line. Glyphs are lg for the same reason — the glyph is the only part that survives a collapse.

The header is a band, not the first row ​

It gets --hab-nav-band-h (56px) — its own token, taller than the 44px rows below it, and shared with the footer. Those two are the parts of the rail that are not destinations, and at the row metric the header read as one more list item with a logo in it.

Neither band draws a rule. A hairline cost a band of rail to say what the gap already says, and an expanded rail now draws no hairlines at all — the only one left is the separator a group label becomes when the rail collapses, which earns it by carrying the boundary the label can no longer show.

brandIcon is the product's mark, so give it a real one; the default is a neutral placeholder. It renders at --hab-icon-xl (24px) against the rows' 20px, in a 36px box that centres it on the column of glyphs below it.

The box does the aligning, not the glyph. Its centre sits 12px of padding plus 18 = 30px in, which is exactly where a row's 20px glyph centres at its own 20px inset. That holds whatever size the mark is, so slotted markup only has to stay inside the box.

The mark had a token of its own for a while — --hab-icon-brand at 32px, on the reasoning that a mark identifies the product rather than labelling a control and so belongs sized against the band it heads. The reasoning holds; the size still did not earn a token. 32px read too large in the band, and a mark that fits inside the ladder's top step needs no step beyond it. HabIconSize stays closed at sm | md | lg | xl and the ladder has four sizes, not five. The wordmark beside it is text-md (16px) — it is the product's name, not a row label.

A real logo goes in the brand slot. brandIcon is one SVG path, and a logo is usually several in several fills, so the slot replaces the mark outright and brandIcon becomes its fallback. Give your markup size-icon-xl and keep it inside the 36px box — that is what lands it on the same column as the glyphs below.

vue
<HabNavDrawer v-model="page" brand="Notitia" :items="items">
	<template #brand>
		<MyCubeLogo class="size-icon-xl shrink-0" />
	</template>
</HabNavDrawer>

The wordmark stays outside the slot, so it is still the lockup's accessible name when brandHref makes it a link.

It is deliberately not a tile. A resting fill made the mark look like a HabIconTile — a component whose whole job is to say "this is an icon in a slot" — and put a second surface inside a band that is already its own surface.

The header is identity only. The mark used to be the collapse button, which worked while it was a menu glyph and stopped working the moment it became a real mark: a hamburger announces what it does and a logo does not. It holds the band in both states, and it is inert unless you give it somewhere to go.

brandHref makes the lockup — mark and wordmark together — a link, usually to the product's home. It is a real <a href>, so an app wanting client-side routing intercepts the click as it would any in-app link.

Its hover moves the ink, not the surface. A wash behind the mark rebuilds the tile this header deliberately does not have, and a wash across the whole band avoids the tile but lights up 56px of rail for a link to home. The ink is enough.

It dims rather than brightening, which is the opposite of the rows. The lockup rests at --hab-nav-fg-strong — pure white, the brightest ink the rail has — so there is nothing above it to brighten into; the rows can go nav-fg → nav-fg-strong only because they rest below the top. Starting the brand dimmer to buy the usual direction would cost it the thing that makes it the brand line. --hab-nav-fg holds 9.2:1 on the rail, so the hovered state is a brightness step, not a readability one.

A slotted logo with its own hardcoded fills will not follow. The fallback mark paints with fill="currentColor"; your markup has to do the same to join in.

brand is required with it. The mark is decorative and the wordmark is dropped when the rail collapses, so that string is the only accessible name the link can have — and without it the lockup stays inert and warns rather than rendering an anchor nobody using a screen reader can identify. A visibly unclickable brand plus a warning beats a link that looks perfect and fails WCAG 4.1.2.

Without brandHref the lockup renders a div: no anchor, no tab stop, nothing to activate.

The toggle docks in the band ​

The chevron sits at the right of the brand band: a double chevron pointing the way the rail will move, which needs no label to be understood and carries collapseLabel / expandLabel anyway. It is a 32px box — a secondary control, smaller than a row, comfortably over the 24px minimum target.

It had its own strip under the header, and that strip cost a row's height of rail for one control. What it occupied was the first group's heading, which is why that heading used to be dropped — and why it is back now. Every group gets a label again, including the first.

Collapsed, the mark is the way back ​

At 60px the band holds the mark or a control, not both. So the chevron is shown only while the rail is open, and collapsed the brand mark takes the job: it becomes a real <button>, and its aria-label swaps to expandLabel so it always announces the job it currently has.

A logo that toggles is normally the wrong answer — a hamburger announces what it does and a logo does not. That objection holds where there is a chevron to point at instead. A collapsed rail has none, and a mark that expands beats no way back at all.

The consequence worth knowing: brandHref does not route while collapsed. The lockup is a link when the rail is open and the expand control when it is not, so home is one click further away on an icon rail. That is the trade for having a way off it.

The dead space under the last row still toggles the rail either way, and it is outside the scroller — the way out of a collapsed rail must not be something you can scroll away from.

Rows that lead somewhere ​

Give an item an href and its row becomes a real <a>, so it can be middle-clicked, opened in a new tab and copied like any other link. Without one it stays a <button>.

Habitus never navigates for you and takes no router dependency, so update:modelValue hands over the event alongside the id. A router-driven app cancels the plain left click and routes itself, and lets a modified click go to the browser:

vue
<HabNavDrawer :items="items" :model-value="current" @update:model-value="go" />
ts
function go(id: string, event: MouseEvent) {
	// A modified click belongs to the browser — that is the new tab.
	if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return;
	event.preventDefault();
	router.push(items.find((i) => i.id === id)!.href!);
}

The second argument is additive: v-model binds the first, and a handler that declares one parameter simply ignores it.

Collapsing ​

60px icon rail. Three things happen, and each is a decision:

  • Group labels become the divider lines they were already implying. The heading has nowhere to go on a 60px rail, but the grouping it expressed is still true. The first group keeps its label open and draws no rule collapsed: a line with nothing above it divides nothing.
  • No row moves. The heading keeps the box its label made, rule or no rule, so collapsing changes the width and nothing else. It is the only element in the rail whose height differed between the states, and it was moving every row below it up by 26px — 43px under the first group. Its height is held by one line box of its own type (h-[1lh]), not by a pinned number that would stop matching the moment --hab-fs-xxs moved.
  • Counts drop. There is no room, and a number with no label is worse than no number.
  • Everything stays mounted. Unmounting the rows would make hover and focus jump, and would throw away the focus a keyboard user was holding.

Each row keeps its accessible name — hidden visually, not removed. A collapsed rail must not become a column of unnamed icon buttons.

A collapsed row names itself to a pointer with a HabTooltip, placed right — a top tip on a 60px rail lands over the row above. It was a title, which has no keyboard, no touch, a browser-chosen delay and inconsistent announcement; the tooltip shows on focus as well as hover, wires aria-describedby and dismisses on Esc, at the system's own 300ms. The collapsed brand mark gets the same. Open, both pass undefined and HabTooltip renders the trigger alone.

Every tip starts at the same x. A tooltip's offset is measured from its trigger, and the rail's two triggers do not share an edge — the brand lockup fills the band, a row sits one list-inset (px-2) inside it. At the default offset the brand's tip would start 8px clear of the rail and every row's flush against it, from a column of icons that all line up. So rows pass offset with that inset added back.

Hand-built rails wrap their own rows. The tooltip sits in HabNavDrawer rather than inside HabNavItem because HabTooltip's no-label branch is a <slot>, and as a row's own root that would cost HabNavItem its single root element — and with it wrapper.element, classes() and attribute fallthrough.

Give every item an icon. It is all that survives the collapse, so an item without one becomes a blank row that cannot be told apart from its neighbours — and the component warns when you build one.

Collapsed, both survive: the mark keeps the header band and the chevron keeps the strip. Neither is competing with the other for a 60px header any more, which is what made this arrangement worth the extra element. The chevron only turns around.

It takes --hab-nav-band-h too. Before, it padded whatever you slotted, so its height was your content plus 16px — an avatar and a name came out cramped against the border, and a rail with a 56px top band ended in a 40px one. The two bands are the parts of the rail that are not destinations, so they share a number.

Slot content is centred in the band, so a footer does not have to bring its own height, and the band mirrors the list's px-2. Give a footer control the row's own px-3 and it lines up with the rows above on both counts:

vue
<template #footer="{ collapsed }">
	<button
		type="button"
		class="flex min-h-row w-full items-center gap-2 rounded-hab-sm px-3 hover:bg-nav-hover"
	>
		<HabAvatar
			name="Ada Lovelace"
			size="sm"
			decorative
		/>
		<span
			v-if="!collapsed"
			class="min-w-0 truncate text-left text-xs"
		>
			Ada Lovelace
		</span>
	</button>
</template>

The band used to hold px-5 — the list's px-2 plus a row's px-3 — which put bare slot content's glyph straight onto the rows' 20px column. That looked right until anything in the footer grew a surface: a control inset 20px each side is 192px wide where a row's is 216, so its hover fill stopped 12px short at both ends of every fill above it. The eye lines up the column of glyphs, and it lines up the edges of filled boxes just as readily.

So bare content in the footer now sits at 8px rather than 20px. Wrap it in a control with px-3, as above, and both the glyph and the box land where the rows do.

One inset, everywhere a filled box can appear. The list, the footer band and the header's chevron all take px-2, so every hover fill in the rail shares its edges with every other — in both states. HabNavDrawer.spec.ts asserts that as a relationship against the list rather than against the number, so moving the list moves the assertion with it.

The dead space toggles too ​

Clicking the empty area under the last row collapses and expands the rail. It is the filler below the list and not the scroll container, so it does not fire on the scrollbar gutter or on the click that ends a drag-scroll.

It is a pointer shortcut and nothing else: aria-hidden, no tab stop, no role. The header's chevron remains the real, named control, so nobody navigating by keyboard or by landmark is handed an unlabelled second target.

The filler is min-h-0: when the list overflows there is no dead space, so there is no shortcut. A 24px floor kept it alive inside the scroller and added 24px of empty scroll below the last row — a shortcut is worth nothing if it invents the space it lives in.

The filler is invisible by design — it exists precisely because nothing is there — so the demo below measures its box and draws it. The shading and the dashed edge come from the demo, not the component: the overlay is positioned from the measured box and is pointer-events-none, so it shows the target without becoming one.

update:collapsed fired 0 time(s)
Room to spare — filler ?px tall
Overflowing list — filler ?px tall: it collapses away rather than adding empty scroll
Click the shaded area under the last row — it toggles the rail. The shading is drawn by this page, not by the component.

Two cases, because the filler is flex-1 min-h-0 and behaves differently in each:

  • Room to spare. The filler takes everything below the last row — a large, obvious pointer target.
  • Overflowing list. It collapses to nothing, so the crowded rail scrolls to its last row and stops. There is no shortcut there because there is no dead space there, which is the right answer: the alternative added 24px of empty scroll to every rail long enough to scroll.

At real scale ​

Sessions
Rail is 232px; this area takes the rest.
The brand button, or the dead space under the last row, collapses it — the width transitions, nothing unmounts

232px only reads as right against the page it takes space from, and a demo stage is not one. Collapse it and watch the width transition: the rows stay mounted, so focus and hover do not jump, and no row moves vertically.

Measured, not asserted ​

Every number above is a metric this repo's checks cannot see. Tailwind emits nothing for a step it cannot generate, so a renamed or invalid w-58 leaves the rail unstyled while lint, typecheck, tests and build all pass — and a spec asserts class strings, which proves the name is on the element and never that the box is the size the name claims. npm run check:classes closes half of that gap, the class existing in the stylesheet, but not whether 232px is 232px on screen.

So both states are rendered side by side and read back with getBoundingClientRect. The bars above each rail are independent yardsticks written as raw w-[232px] / w-[60px], so a token that moved could not move the reference with it: if a bar's right edge does not line up with its rail's, the width is wrong whatever the table says.

232px
60px
All 10 judged metrics match.
Row drift could not be measured.
MetricOpenCollapsed
Rail width
w-58, not the w-60 (240px) it was rounded to
not rendered want 232 · ·not rendered want 60 · ·
Header band
min-h-nav-band, its own token — not the 44px row metric
not rendered want 56 · ·not rendered want 56 · ·
Footer band
the same band metric, not padding round the slot
not rendered want 56 · ·not rendered want 56 · ·
Toggle strip
min-h-row: pt-3 clear of the band, plus the 32px control, is exactly 44
not rendered want 44 · ·not rendered want 44 · ·
Toggle hit area
a secondary control: same size in both states, over the 24px minimum
not rendered want 32 · ·not rendered want 32 · ·
Toggle glyph
icon md, the source's size for this control
not rendered want 18 · ·not rendered want 18 · ·
Brand lockup box
the band's 56px less its 1px border, so the rule stays visible
not rendered want 55 · ·not rendered want 55 · ·
Brand glyph
--hab-icon-xl, the ladder's top step; it holds the header in both states
not rendered want 32 · ·not rendered want 32 · ·
Item row height
min-h-row, not min-h-row-compact (36px)
not rendered want 44 · ·not rendered want 44 · ·
Item glyph
icon lg, not md (18px)
not rendered want 20 · ·not rendered want 20 · ·
Section label block
the same box in both states — the rule replaces the label inside it
not rendered ·not rendered ·
Measured in the browser with getBoundingClientRect, against the numbers the design source specifies

Worth knowing while reading it:

  • Both bands are 56px in both states. If one reads 44px, min-h-nav-band did not generate and the band fell back to its content height — which is exactly the failure this is here to catch, since a 44px control plus a border makes 45px look deliberate.
  • No row moves between the two states. The one claim here that is not a comparison against a constant: each row's top is read relative to its own rail and the two lists are compared position by position, so any element between them that changes height shows up. The group heading was the whole of it.
  • The mark carries no resting fill, which the table cannot see — look at it. There should be no square behind the glyph until the pointer is on it.
  • The section label block is reported, not judged. Its height is padding plus an xxs line box, not a round number worth asserting. What matters is that the label has visibly more air above it than the rows have between them.
  • The first reading can be the fallback font's. Roboto lands after first paint and changes every line box with it, so the harness re-reads on document.fonts.ready. Re-measure is there for anything else that settles late.

What this still cannot check ​

The pointer behaviour belongs to the browser layer this repo has not set up yet (@vitest/browser + Playwright), and happy-dom cannot answer any of it. Until that exists, check it here by hand:

  • Clicking the scrollbar gutter of the crowded rail must not toggle. The filler is the region below the list, not the scroll container, precisely for this.
  • Drag-scrolling the crowded rail and releasing over the filler must not toggle.
  • The filler must take no tab stop: tab through the rail and focus should go chevron → rows → footer, never into the shaded area and never onto the mark.

HabNavItem ​

One row in a dark rail, for hand-building one instead of passing items.

vue
<HabNavItem label="Inbox" :icon="mdiInboxOutline" :count="12" active />

It reads --hab-nav-fg / --hab-nav-fg-2, so it only looks right inside a --hab-nav surface. Used on a page it will be near-invisible.

The design source ships this twice — NavRow with the label as a prop and NavItem with it as children — having already consolidated a third copy that had drifted inside the drawer. In Vue that split has no reason to exist: a label prop with a default slot that overrides it is one component covering both call shapes, the same way HabListItem does. One implementation is the point the source was making; this just finishes it.

HabNavSection is the uppercase group label, and the divider it becomes when collapsed. Pass at-top on the first group of a hand-built rail. It does two things: suppresses the collapsed rule, because a line with nothing above it divides nothing, and takes a tighter top padding, because the space above it is the header band rather than the run of rows every other heading has to push away from. The label still renders when the rail is open — a heading at the top of a list is not the same claim as a line at the top of one.

HabNavDrawer passes it for the first group. It used to render no first heading at all, that space being the collapse toggle's strip; the toggle now docks in the brand band and the heading has it back.

Accessibility ​

  • The rail is a named nav landmark. A page with more than one nav landmark needs each named, so the component warns without a label.
  • The active row carries aria-current="page".
  • The collapse toggle is named for what it will do, so it takes two props rather than one: expand-label while collapsed, collapse-label while open. Neither has a default — one prop could not name both states without Habitus shipping the other half in English. It reports aria-expanded either way.
  • Rows are a flat list with headings interleaved, not nested lists: the rail is one sequence of destinations, and nesting would have a screen reader announce a list of lists.
  • A collapsed section divider is role="separator", and it is not hidden: it is the only thing left carrying the boundary the label carried, so a collapsed rail that hid it conveyed no grouping at all.
  • A linked brand lockup is named by its wordmark when the rail is open and by brand when it is collapsed. brandHref without brand renders no link at all.
  • The active row's hover darkens its fill, and deliberately does not use --hab-accent-hover: that leg lightens the accent in dark mode, where the ink on it is white for all six product accents — 2.98:1, under AA. Mixing toward --hab-dark-color moves the same way in both themes and can only help white ink, measuring 5.78 at worst against 4.32 on the resting fill.
  • The brand mark is decorative, and the lockup around it is inert unless brandHref gives it a destination. The collapse control is a real <button> with a name in both states.
  • The width transition is dropped under prefers-reduced-motion.

API ​

Props ​

PropTypeDefaultDescription
brandstringundefinedBrand wordmark. Hidden when collapsed.
brandHrefstringundefinedMakes the brand lockup — mark and wordmark together — a link to this href. Usually the product's home. Without it the lockup is inert: no anchor, no tab stop, nothing to activate. It is a real <a href>, so it navigates. An app that wants client-side routing should intercept the click, as it would for any other in-app link. brand is required with it. The mark is decorative and the wordmark is dropped when the rail collapses, so that string is the only accessible name the link can have — and without it the lockup stays inert and warns rather than rendering an anchor nobody using a screen reader can identify.
brandIconstringundefinedThe product's mark, as SVG path data. Rendered at --hab-icon-xl (24px). Inert unless you give it brandHref — the collapse toggle is its own control, since a logo does not announce that it collapses anything. A real logo is usually more than one path in more than one fill, and this prop cannot carry that. Use the brand slot for those; it replaces the mark and this prop is its fallback.
collapsedbooleanfalseControlled collapse. Bind with v-model:collapsed for two-way.
collapseLabelstringundefinedAccessible name for the same button while the rail is expanded.
expandLabelstringundefinedAccessible name for the rail's toggle while it is collapsed — the button expands it. No default: the string is the host application's to translate.
itemsHabNavDrawerItem[][]—
labelstringundefinedAccessible name for the rail.
modelValuestringundefinedThe active row's id. Bind with v-model.

Events ​

EventPayloadDescription
update:collapsedcollapsed: boolean—
update:modelValueid: string, event: MouseEvent—

Slots ​

SlotBindingsDescription
brandanyThe product's mark. Defaults to brandIcon rendered as a single 24px path, so a real logo — more than one path, more than one fill — comes in here instead. Give your markup size-icon-xl so it lands on the rows' glyph column.
footer{ collapsed: boolean }Footer content, usually a HabAvatar plus a name. Receives the collapsed state.

HabNavItem ​

Props ​

PropTypeDefaultDescription
activebooleanfalse—
collapsedbooleanfalseIcon-only rail row: hides the label and count, centres the icon and titles the row. HabNavDrawer sets it on its rows when collapsed.
countstring | numberundefinedTrailing count, rendered tabular. Dropped when collapsed.
disabledbooleanfalse—
hrefstringundefinedRenders as an anchor instead of a button.
iconstringundefinedSVG path data from @mdi/js. Effectively required: it is all that survives a collapse.
labelstringundefinedThe row label. Or use the default slot.

Slots ​

SlotBindingsDescription
defaultanyThe label, in place of the prop.
leadinganyLeading adornment, replacing the glyph. Takes precedence over icon.

HabNavSection ​

Props ​

PropTypeDefaultDescription
atTopbooleanfalseThis group is the first thing in the rail. Two things follow: its collapsed divider is suppressed — a rule with nothing above it is just a second line under the header — and it takes LESS TOP PADDING, because the space above it is the header band rather than the run of rows every other heading has to push away from. The label's box is kept either way, so the rows do not move. HabNavDrawer passes it for the first group. It used to render no first heading at all, the space being the collapse toggle's strip; the toggle now docks in the brand band and the heading has it back.
collapsedbooleanfalseIcon-rail form: the label becomes the divider it was implying.
labelstringundefinedThe group label. Or use the default slot.

Slots ​

SlotBindingsDescription
defaultany—

Proprietary — internal use only.