Skip to content

HabAccordion ​

Sections that take turns.

Input, output and monitoring are configured per device.

Sections that take turns — the chevron leads the header
vue
<HabAccordion v-model="open" bordered>
	<HabAccordionItem
		value="audio"
		label="Audio"
		secondary="3 devices connected"
		meta="3"
	>
		<p>Input, output and monitoring are configured per device.</p>
		<template #action>
			<HabButton
				variant="text"
				size="sm"
				>Reset</HabButton
			>
		</template>
	</HabAccordionItem>
</HabAccordion>

Against its neighbours ​

HabTabs when the sections are peers and only one can be relevant at a time — the content swaps in place. HabAccordion when the reader may want two open at once, or when the set is long enough that stacked headers beat a row of tabs.

Never wrap a form's required fields in one. A collapsed section hides an error the user has to fix, and nothing about the collapsed header says the problem is in there.

The chevron leads ​

It sits before the label rather than after it, and rotates in place. At the end of a wide panel a trailing chevron drifts away from its own label and collides with the action slot.

The action slot is outside the toggle ​

A button inside a button is invalid markup and the inner one stops responding to clicks. So the header is a row holding the trigger and, beside it, whatever controls belong to the section — not a trigger that contains them.

The reveal ​

Height is measured by the primitive and published as --reka-accordion-content-height, because height: auto is not a value CSS can animate to. The transition runs on --hab-dur-reveal / --hab-ease-out and is dropped entirely under prefers-reduced-motion.

Nesting: a section inside a section ​

each Cover line ›  Billing address cover_lines[]. billing_addressnone defined

A containment tree: one checkbox at every depth, one regular step per level
vue
<HabAccordion
	v-model="open"
	multiple
	flush
>
	<HabAccordionItem value="policy" label="Policy" secondary="this dataobject">
		<HabCheckbox label="Validate policy" class="w-full pl-4 pr-3" />

		<HabAccordion nested flush>
			<HabAccordionItem value="cover" label="each Cover line" />
		</HabAccordion>
	</HabAccordionItem>
</HabAccordion>

nested owns the containment metric rather than leaving it to the call site: 12px of indent, 8px of air above, a 1px rail down the left edge, and a surface-2 strip behind each header. Two levels is what it is specified for.

The rail is border-default, not a divider. A divider separates repeated items inside one surface; a containment rail bounds a region. At divider weight it disappears on dark and the indent becomes the only depth cue left.

nested also turns divided off by default, since each nested header draws its own top rule and a second hairline between siblings would double it. Pass :divided="true" to get them back.

Full-bleed bodies ​

flush removes the body's padding entirely, so what is inside carries its own inset.

Reach for it whenever the body is a list of rows. Padding on the body insets the whole list — rows cannot reach the panel's edge, hover bands stop short of it, and a nested set's 12px indent gets measured from the wrong place, so the depth steps stop being regular. With flush and a 16px row inset — HabInspectorPanel's column — the checkbox column lands at 16 / 29 / 42: one step per level.

A nested set needs a flush parent. Otherwise the parent's body padding is added to the indent and the ladder drifts — so the component warns when it finds itself in a section that is not flush. Either the set or the single section holding it can supply it; the check reads whichever value actually applied.

A heading that does not disclose ​

:disclosable="false" keeps the whole heading anatomy and drops the disclosure: no chevron, no body, and not a button. The chevron's width is held as a spacer so the label column stays aligned with its siblings, and the label takes fg-2.

It is for a section with nothing in it — which is a different statement from a section with things in it you have not chosen yet. Rendered alike, the two differ by one digit in the smallest type on the row, and the reader who opens the empty one and finds nothing learns to skip the ones that did have work in them.

It is also not disabled. That is a section you may not open yet, and it is still a button.

Markup in the header ​

label, secondary and meta each take a slot as well as a prop, and the slot wins.

Reach for them when the header needs more than a string: a path whose tail must survive truncation, a glyph beside a count, or a word where a number would otherwise go.

The label slot carries no weight of its own, unlike the prop — a two-part label sets its own leaf apart, and a font-medium on the wrapper would flatten that back out. The meta slot keeps the recessive ink and tabular figures, because those are what make a column of them readable down the page.

To keep the fraction column aligned on a heading that has no action, pass an empty action slot rather than omitting it — the slot is reserved, not absent.

A two-part label that truncates from the left ​

A header naming something nested — each Cover line › Tax detail — must lose the ROUTE when space runs out, not the destination. A plain truncate does the opposite: it keeps each Cover line › Tax det… and drops the only segment that identified the thing.

Habitus does not ship a component for this; the markup is three spans:

vue
<template #label>
	<span class="flex min-w-0 items-baseline">
		<span class="min-w-0 shrink-[999] truncate">each Cover line ›&nbsp;</span>
		<span class="min-w-0 truncate font-medium">Tax detail</span>
	</span>
</template>

Three things about it are load-bearing, and two of them are easy to get wrong:

  • shrink-[999] on the prefix, so it is spent first and spent almost entirely before the leaf moves at all.
  • The leaf keeps min-w-0 truncate and its default shrink. Do not give it flex-none. A pinned leaf cannot shrink, so a long one at a narrow width overflows its parent and is clipped with no ellipsis, pushing the meta column out of view — and it looks perfect at whatever width you tested. "Gives way last" is the goal; "cannot give way" is the bug.
  • Weight separates the two segments, never ink. A darker leaf puts a visible break exactly where the prefix's scope has to continue, and readers conclude the leaf runs once per the wrong thing.

The separator belongs to the prefix string — it differs per trail (›, /, .), and a breadcrumb's prefix is several segments already joined.

Truncation is visual only, so carry the full path in a title or a HabTooltip on the row.

shrink-[999] is not in the published stylesheet

dist/habitus.css is compiled from src/ alone, so it only contains utilities a Habitus component emits — and none emits this one. An app that runs its own Tailwind gets it by importing @livereader/habitus-vue/theme; an app that only imports @livereader/habitus-vue/styles needs style="flex-shrink:999" instead.

Density ​

Headers sit on the same row ladder as HabList and HabDataTable — 44px, 36px compact, or 52px comfortable. The tall step is for two-line rows; see HabList.

Closing the last section ​

A single accordion reports undefined when its only open section closes. reka says null there; it is normalised so a consumer's ref<string>() stays idiomatic rather than having to admit a third empty value.

With multiple, v-model is an array instead.

Accessibility ​

  • Each panel is a region labelled by its own header, which is the link the APG accordion pattern requires and the one a screen reader follows.
  • aria-expanded tracks each header, and a disabled section is disabled natively.
  • Known limitation: reka renders the trigger's aria-controls as an empty string rather than the panel's id. The panel's id exists and its aria-labelledby points back at the trigger correctly, so the relationship is expressed — but the trigger half of it is not. Primitive markup, not ours; worth raising upstream.

API ​

Props ​

PropTypeDefaultDescription
borderedbooleanfalseWraps the set in a card border. Use it when not already inside a HabPanel.
density"default" | "comfortable" | "compact""default"Header height, on the same ladder as HabList and HabDataTable — so a list, a table and an accordion stack without the page reading as three rhythms.
dividedbooleanundefinedHairlines between sections. Defaults to on, except in a nested set, where each header draws its own top rule and a second hairline between siblings would double it.
flushbooleanfalseFull-bleed bodies: the section content gets no padding of its own, and what is inside it carries its own inset instead. Reach for it when the body is a list of rows. Padding on the body insets the whole list, so rows cannot reach the panel's edge, hover bands stop short, and a nested set's indent is measured from the wrong place.
modelValuestring | string[]undefinedOpen section id, or ids when multiple. Bind with v-model. A single accordion reports undefined when its only open section closes — reka says null there, which is normalised so a consumer's ref<string>() stays idiomatic.
multiplebooleanfalseAllow several sections open at once.
nestedbooleanfalseThis set is a section inside another section's body. It takes the containment metric: indented 12px from its parent's content, 8px of air above it, and a 1px rail down its left edge. Its headers take a surface-2 strip and their own top rule, which is what makes depth visible without reading — on dark the indent alone is not enough. The rail is border-default, NOT a divider: a divider separates repeated items inside one surface, and this bounds a region. At divider weight it disappears on dark, which was the review's finding. Nesting a set only works inside a flush parent — otherwise the parent's body padding is added to the indent and the depth steps stop being regular.

Events ​

EventPayloadDescription
update:modelValuevalue: string | string[]—

Slots ​

SlotBindingsDescription
defaultanyHabAccordionItem children.

HabAccordionItem ​

Props ​

PropTypeDefaultDescription
density"default" | "comfortable" | "compact"undefinedOverrides the parent's density for this section alone.
disabledbooleanfalse—
disclosablebooleantrueWhether this section opens at all. false keeps the whole heading anatomy and drops the disclosure: no chevron, no body, and not a button. This is the answer to a section with NOTHING IN IT, which is a different statement from a section with things in it that you have not chosen yet. The two rendered alike — same chevron, same closed state — and the design review called it the one broken state in the set: a reader opens the empty one expecting choices, finds a sentence, and then learns to skip the ones that did have work in them. The chevron's width is held as a spacer, so the label column stays aligned with its disclosable siblings, and the label takes fg-2 — present, but not pending. disabled is the other thing this is not: that is a section you may not open yet. This is one there is nothing to open.
flushbooleanundefinedOverrides the parent's flush for this section alone.
iconstringundefinedSVG path data (mdiCogOutline from @mdi/js), after the chevron.
labelstringundefinedThe header's label. The label slot wins over it.
metastringundefinedShort right-aligned value in the header — a count, a size. Tabular. The meta slot wins over it, and is the way to put a glyph beside the value.
secondarystringundefinedSecond header line: count, path, status summary. One line, truncated.
value*string—Required — this is what the accordion's modelValue speaks in.

* required

Slots ​

SlotBindingsDescription
actionanyControls at the right edge of the header, outside the toggle.
defaultanyThe section body.
labelanyThe header's first line, in place of label. Takes markup — most often a two-part path whose prefix shrinks and whose leaf survives. Unlike the prop it carries no weight of its own: a two-part label sets its leaf apart itself, and a font-medium here would flatten that back out. The docs page carries the three-span recipe for that path, and the two ways to get it wrong — a pinned leaf clips without an ellipsis, and an ink step between the segments reads as a break in scope.
leadinganyLeading adornment, replacing the glyph. Takes precedence over icon.
metaanyThe header's trailing value, in place of meta. Takes markup — a glyph beside a count, or a word where a number would otherwise be. Laid out as a row so a glyph and its value sit together; the recessive ink and tabular figures stay, because they are what makes a column of these readable down the page.
secondaryanyThe header's second line, in place of secondary. Takes markup.

Proprietary — internal use only.