HabButton
Every action. Renders a native <button> by default, or any element via as.
<HabButton>Save</HabButton>Two axes, not one
variant is the surface treatment; tone is the semantic color role. They are independent and they multiply, which is why there is no single danger variant — a destructive action can be a filled button, a quiet tonal one or a bare text link, and all three need to read as destructive.
<HabButton tone="error" variant="outlined">
Delete
</HabButton>Pick variant for how much weight the action deserves on the page, and tone for what kind of action it is. Use one flat accent button per view — if two compete, neither reads as primary.
neutral deviates from the pattern in two places, deliberately: neutral flat is a fill (--hab-contained) because there is no neutral base color, and neutral outlined borders --hab-border-strong rather than the text color, which at full-strength ink would read far heavier than the other three tones.
Sizes
Sizes map to the shared --hab-control-* scale, so a button lines up with any other control of the same size. Each step carries its own icon size — 16 / 18 / 20 — so a glyph never looks stranded in a taller control.
Icons
icon takes SVG path data, which is what every @mdi/js export is. There is no name lookup and no blessed subset: import the glyph you want and bind it. Treeshaking stays on your side, and a misspelled import is a typecheck failure rather than a control with no glyph.
A handoff that specifies icon: content-save transcribes by one rule — kebab-case to mdi plus PascalCase — so content-save is mdiContentSave.
<script setup>
import { mdiContentSave, mdiRocketLaunch } from "@mdi/js";
</script>
<template>
<HabButton :icon="mdiContentSave">Save</HabButton>
<HabButton :icon="mdiRocketLaunch">Launch</HabButton>
</template>For an adornment that is not an icon at all, use the leading or trailing slot. A slot wins over the prop if both are given.
The leading slot is also the way in for a glyph whose identity is only known at runtime — one named by an API response, or typed by a user. Path data cannot travel through JSON, and no curated name list can cover an open set, so pass your own markup instead: an <i class="mdi mdi-…"> if you load the @mdi/font webfont, or a raw <svg>.
iconOnly drops the label and makes the button a square box at the control height. It needs an aria-label: the icon is decorative, so without one the button has no accessible name — Habitus warns in the console when that happens.
<HabButton :icon="mdiPencil" icon-only aria-label="Edit" />It keeps the normal control radius, not a circle. Every icon affordance in the system shares that radius, so the same job reads as the same component wherever it appears, and an icon button sits flush beside a labelled button or a field.
shape="circle" is an explicit opt-in for detached actions only — a FAB, or a close button floating over a scrim.
The reason is not decoration: in this system a full circle means identity or state — an avatar, a timeline dot — not "do this". A circular button sitting in a toolbar reads as the wrong kind of thing.
<HabButton :icon="mdiPlus" icon-only shape="circle" aria-label="New session" />shape applies only together with iconOnly; a labelled button always takes the control radius, and Habitus warns if you ask for a circle without iconOnly.
States
disabled and loading are separate props. Both block interaction, but they say different things: disabled means unavailable, loading means busy. Assistive technology announces them differently, so reach for the one that is true.
<HabButton disabled>Unavailable</HabButton>
<HabButton loading>Saving</HabButton>Disabled removes the tone treatment rather than layering over it, so no hover rule is left in place to fire — a second lookup table by variant, not hover: overrides on the first.
It keeps the variant's shape and gives up only its tone. flat and tonal were filled, so their fill goes grey; text and outlined were not, and stay transparent, with outlined keeping its border in the quietest line the system has. Dropping the table wholesale used to give every variant the grey fill, which made an unavailable text button heavier than an available one — a row of disabled icon buttons read as a row of grey blocks.
loading is the same look: both funnel into one flag. The spinner takes the icon's position rather than sitting beside it, so a button does not change width when it becomes busy.
Full width
Rendering as another element
Use as="a" for anything that navigates. A link that looks like a button should still be a link — it belongs in the browser's history and supports open-in-new-tab, which a <button> does not.
<HabButton as="a" href="/settings">
Settings
</HabButton>Code values
mono sets the label in the mono face, for a value that is code rather than a word — a type, an id, an environment variable's name. It replaces the sans rather than adding to it: a font-mono class passed from outside would compete with the component's own font by stylesheet order, and lose or win by accident.
Focusing it from code
focus() moves focus to the button — the rendered element, whatever as makes it. A disabled or loading button does not take focus: a native one is disabled, and any other element is out of the tab order while inert, so focus() leaves it alone too. It takes the browser's FocusOptions. Type the template ref with InstanceType:
<script setup lang="ts">
import { useTemplateRef } from "vue";
import { HabButton } from "@livereader/habitus-vue";
const field = useTemplateRef<InstanceType<typeof HabButton>>("field");
// later: field.value?.focus();
</script>Accessibility
- Renders a real
<button>by default, so keyboard and screen-reader behaviour is the platform's rather than something reimplemented. - The focus indicator is the browser's own. No outline is reset and no custom ring is drawn. An earlier design-system version shipped a 3px halo in an ~8% wash that measured about 1.1–1.3:1 against every surface in both themes — it fired everywhere and was visible nowhere.
loadingsetsaria-busy;disabledandloadingboth setaria-disabled.disabledis only applied as an attribute when rendering an actual<button>, since it is not valid on other elements. Any other element gets a pointer trap instead, because only a native button ignores clicks when disabled.- Icons are
aria-hiddenwithout exception. They sit beside a label, or the control carries its own — an icon that needed its own name would mean the control around it is missing one.
You are still responsible for aria-label on an iconOnly button, and for giving an as="a" button an href — an anchor without one is not focusable.
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
as | string | "button" | Element or component to render as. Use a for navigation. |
block | boolean | false | Stretches the button to the full width of its container. A Habitus addition — not part of the design system's Button contract. |
disabled | boolean | false | Disables interaction and marks the button unavailable. |
icon | string | undefined | Leading icon as SVG path data — :icon="mdiCheck" from @mdi/js. The leading slot takes precedence if both are given, and is the way in for a glyph whose identity is only known at runtime. |
iconOnly | boolean | false | Renders an icon-only button whose width equals its height, with the label omitted. Pass aria-label — an icon is not an accessible name. |
loading | boolean | false | Shows a busy state and blocks interaction. Kept separate from disabled so assistive technology announces "busy" rather than "unavailable". A Habitus addition — not part of the design system's Button contract. |
mono | boolean | false | Sets the label in the mono face: for a value that is code — a type, an id, an environment variable's name — not a word. One font, never both: a mono class added from outside would compete with the component's own sans by stylesheet order. |
shape | "square" | "circle" | "square" | The icon-only silhouette. Only applies together with iconOnly; a labelled button always takes the control radius. |
size | "md" | "sm" | "lg" | "md" | Control height, aligned to the shared --hab-control-* baseline. |
tone | "error" | "neutral" | "accent" | "success" | "accent" | Semantic color role. Use error for destructive actions. |
variant | "flat" | "tonal" | "text" | "outlined" | "flat" | Surface treatment: how much visual weight the button carries. |
Slots
| Slot | Bindings | Description |
|---|---|---|
default | any | Button label. Omitted when iconOnly is set. |
leading | any | Leading adornment, replacing the glyph. Takes precedence over icon. |
trailing | any | Trailing adornment. |
Exposed
| Member | Type | Description |
|---|---|---|
focus | (options?: FocusOptions) => void | Moves focus to the button — the rendered element, whatever as makes it. A disabled or loading button does not take focus: a native one is disabled, any other is out of the tab order and focuses only because it is told to, so it is skipped too. |