HabModal
A centred dialog over a scrim, for a decision that must be made before continuing.
<HabModal v-model:open="open" title="Move to archive?" note="The session stays searchable.">
<p>Archiving hides this session from the active list.</p>
<template #actions>
<HabButton
variant="text"
tone="neutral"
@click="open = false"
>Cancel</HabButton
>
<HabButton @click="open = false">Archive</HabButton>
</template>
</HabModal>Never for something the user did not have to act on
A modal takes the screen, the focus and the keyboard. Spending all three on information is the most common way this component gets misused — if the user does not have to answer, it belongs in a HabBanner or a HabToastHost toast.
The escalation ladder among the overlays:
| Need | Use |
|---|---|
| A label for an icon | HabTooltip |
| Content anchored to a trigger | HabPopover |
| Confirmation of something that is done | HabToastHost |
| A decision, before anything continues | HabModal |
Size
sm is a confirm, md holds a short form, lg is the one that needs a table or two columns, and xl (1056) is a workspace — an editor with panes of its own. Widths come from --hab-modal-*; pick by content, not by taste.
A workspace: flush and title-append
An editor inside a dialog lays out its own regions, so it wants things a confirm does not:
flushdrops the body's padding and lets it fill the sheet under a rule, so panes reach the edges. Pair it withxl.- The sheet takes the full available height instead of growing with its content, so a workspace does not jump as its panes change.
- The body's one root element is stretched to that height. A pane inside it needs nothing but
overflow-y-autoto scroll by itself — give the body a single root and lay the panes out in it. - A click beside the sheet does not close it. A workspace holds edits, and a stray click must not discard them. Escape and the × still close it; to confirm unsaved changes, control
v-model:openand keep ittrueuntil the user has answered. - The
title-appendslot sits beside the title for what the dialog is about — the record it edits, a read-only badge. It is kept OUT of the title element on purpose: the title is the dialog's accessible name, and a name that absorbed every chip would be read out as one long run-on.
<HabModal v-model:open="open" title="Axiom" size="xl" flush close-label="Close editor">
<template #title-append>
<HabBadge tone="neutral">Order.shippingTier</HabBadge>
</template>
<div class="grid grid-cols-[240px_minmax(0,1fr)]">
<ul class="overflow-y-auto">…</ul>
<div class="overflow-y-auto">…</div>
</div>
<template #actions>…</template>
</HabModal>xl and flush are additions to the design source, which sizes a modal sm/md/lg; see design-source.md.
tone="error" changes the primitive, not just the color
A destructive dialog does two things differently: the title takes error ink, and the scrim stops dismissing it, so the decision cannot be dropped by a stray click.
Underneath, that is not a styled dialog — it is an alertdialog. The difference is the part styling cannot fake: assistive tech announces an alertdialog's contents on open rather than waiting to be explored. A confirmation the user has to hear is exactly the case that role exists for.
Put the consequence in note. For a destructive dialog it is the sentence that does the work — "the recording and all 412 messages are deleted permanently" — and it becomes the dialog's description.
Blocking
dismissible: false removes the close affordance and makes both Esc and the scrim inert, for a decision that has to be answered in the footer.
Use it rarely. A dialog with no exit and no actions is a trap, and the component warns when you build one.
HabFormActions
Not a dialog — the sticky save bar at the foot of a settings form, for when the form is longer than the viewport and a footer button would sit below the fold.
<HabFormActions note="Unsaved changes">
<HabButton
variant="text"
tone="neutral"
>Discard</HabButton
>
<HabButton>Save</HabButton>
</HabFormActions>It ships beside HabModal because the two share one rule: Discard sits left of Save, never the reverse. The confirming action is last in reading order in both, so muscle memory carries between them.
Accessibility
titleis the accessible name, viaaria-labelledby. It is not optional — the component warns without one, because a dialog announced as "dialog" tells the user nothing.notebecomes the description, viaaria-describedby.- Focus moves in on open, is trapped while open, and returns to whatever opened it on close — all from the primitive. The design contract hand-rolls this and its own comment says why it must exist: "the scrim stops the mouse and nothing stopped the keyboard." The primitive additionally handles what those 20 lines miss — nested overlays, scroll locking, and a trigger that unmounts while open.
- The rest of the page is hidden from the accessibility tree while open.
- Not asserted in the specs: the focus trap itself. happy-dom has no layout, so
offsetParentis null for every element and "which elements are reachable" has no answer. It needs the browser test layer — see architecture.md. - Known limitation: with the dialog open, axe reports
aria-hidden-focusagainst reka's own focus sentinels. Primitive markup, not ours.
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
closeLabel | string | undefined | Accessible name for the close affordance. Required unless dismissible is false: Habitus ships no default, so the string is the host application's to translate. |
dismissible | boolean | true | Set false for a dialog the user must answer: no close affordance, Esc and the scrim both inert. Use it only when there is genuinely no way out but a choice in the footer — a dialog with no exit and no action is a trap. |
flush | boolean | false | A workspace: the body loses its padding and fills the dialog under a rule, for content that lays out its own regions — panes, a sidebar, a toolbar — and must reach the sheet's edges. The sheet takes the full available height, and the body's one root element is stretched to fill it, so a pane scrolls with just overflow-y-auto. A click outside no longer dismisses it — it holds edits — while Escape and the × still do. |
note | string | undefined | Second line under the title — what this will affect. |
open | boolean | undefined | Controlled open state. |
size | "md" | "sm" | "lg" | "xl" | "md" | — |
title | string | undefined | The decision being asked for. Becomes the dialog's accessible name. |
tone | "error" | "neutral" | "neutral" | — |
Events
| Event | Payload | Description |
|---|---|---|
update:open | value: boolean | — |
Slots
| Slot | Bindings | Description |
|---|---|---|
actions | any | Footer buttons. Cancel first, the confirming action last. |
default | any | The body — what the decision is about. |
title-append | any | Beside the title, not inside it: what the dialog is about — the record it edits, a state badge. Kept out of the title element so the dialog's accessible name stays the title alone rather than absorbing every chip. |
HabFormActions
Props
| Prop | Type | Default | Description |
|---|---|---|---|
note | string | — | Left-aligned status — "Unsaved changes", "Saved 2 minutes ago". |
Slots
| Slot | Bindings | Description |
|---|---|---|
default | any | The buttons: Discard, then Save. |