Theming
The token values are measured, not chosen. Every -fg and -text color is a contrast-tested pair against a specific partner, and the odd-looking numbers (34px control height, 10px card radius, 45% text mix) are either what the shipped product uses or the result of a measurement. Provenance is in design-source.md; change a value only with a new measurement behind it.
The one thing that is a placeholder is --hab-accent: it ships an unbranded grey so an unbound surface looks unbound. It is also the only color a product has to think about — the surfaces, the lines and the ink all derive from it, so binding one value tints the whole neutral set toward the brand. See Binding the accent.
The four-leg rule
Read this before using any color. Every semantic color is a quartet, and three of the four are easy to confuse:
| Leg | Job | Legal use |
|---|---|---|
--hab-<name> | the base | Borders, icons, bars, a solid fill. Never text — measured at the 3:1 floor. |
--hab-<name>-tint | the base as a background | Background only. Solid, never alpha. |
--hab-<name>-text | ink on that tint | The only color readable on -tint. |
--hab-<name>-fg | ink on the base at full strength | Solid buttons, filled chips, the checkbox mark. Never assume white. |
The accent adds a fifth: --hab-accent-on is the accent used as text on a neutral surface — links, and text/tonal/outlined buttons. -on, -text and -fg are three different jobs, and swapping them is the most common defect in this system's history.
Two consequences worth stating outright: never put base-colored text on a tint, and -fg is not always white — --hab-warning-fg is ink in both themes, because white on the warning base measures 3.48:1.
Importing
Most apps import the compiled stylesheet and get everything:
import "@livereader/habitus-vue/styles";An app that runs its own Tailwind build does not import /styles. Its CSS entry imports /theme, /base and /page — /base is not optional, or the two documented focus indicators never fire, and /page is the only thing left that sets the page's ink and type — and adds an @source for Habitus's dist. That file must be the first stylesheet the app loads, because cascade layer order is fixed by first appearance.
The full file, and why each line is there, is in Getting started → If your app runs its own Tailwind. It is kept in one place so the two guides cannot drift apart.
How it works
Two layers, deliberately:
- Raw values —
--hab-*custom properties on:root, re-pointed under.dark. These are the runtime contract. - Tailwind namespaces — a
@theme inlineblock mapping them into--color-*,--spacing-*,--radius-*,--text-*and friends so they generate utilities (bg-surface,text-fg-2,h-control-md).
inline is load-bearing. Without it Tailwind emits --color-x: var(--hab-x) on :root, where the inner var() resolves at its declaration site — so a .dark override on a nested container would never take effect.
Tailwind's theme must never hold a literal value. A hex in @theme breaks theme switching, per-product accent binding and every color-mix() in the components, all at once. Every entry is a var(--hab-*) reference.
Two things Tailwind v4 cannot name
There is no --duration-* or z-index theme namespace, so those two read the token directly. --hab-elev-0 is also unmapped: its value is none, which is illegal inside Tailwind's composed box-shadow list — use shadow-none.
The two that do have a form:
<div class="duration-(--hab-dur-hover) z-(--hab-z-popover)"></div>Binding the accent
A product binds three legs. theme.css derives the other six, and 17 neutral tokens besides, with color-mix() in plain CSS — no build step:
:root {
--hab-accent: #5f3dc4; /* the FILL */
--hab-accent-fg: #ffffff; /* ink ON the fill — measure it */
--hab-accent-on: #5f3dc4; /* the accent as TEXT and LINES */
}
.dark {
--hab-accent: #8469c5; /* fill: a DARKENED step, so white still reads on it */
--hab-accent-fg: #ffffff;
--hab-accent-on: #d8ccf6; /* text: a LIGHTENED step — the opposite direction */
}That is the whole binding. Do it after importing the Habitus stylesheet.
Why the fill and the text are two legs
--hab-accent is what bg-accent paints, and only that. --hab-accent-on is the accent read against a surface rather than sat on: link text, a tonal, text or outlined button's label, a focus indicator, a selected border, a selection ring.
In light mode they are usually the same value. In dark mode they move in opposite directions — the fill darkens so that white stays legible on it, while the text lightens so it stays legible on a dark page. One value cannot serve both, and collapsing them is the defect this split exists to prevent.
--hab-accent-fg is the third because white is not always right. It is the ink on the fill at full strength, and a light accent needs dark ink there.
What derives, and what to measure
Six accent legs derive from the fill in both themes and need no binding: -hover, -text, -tint, -tonal, -tonal-hover and -ghost.
So do the neutrals. --hab-bg, --hab-surface, --hab-surface-2, --hab-contained, --hab-contained-2, --hab-divider, --hab-line, --hab-border-strong, the four ink steps and the six --hab-nav-* tokens are each color-mix()ed from a few per cent of the accent into a fixed grey.
The grey is fixed on purpose. The mix anchors the lightness and borrows only the hue, so a rebound accent re-tints every surface without moving the lightness each pair was measured against. It does still move them a little — hue carries luminance, so the same formula lands differently under a purple than under a teal — which is why tests/contrast.spec.ts re-measures every pair for every product accent rather than for one. A mix anchored on another themed token (var(--hab-surface) rather than a literal grey) would drift the lightness as well, and the measurement would not survive at all.
The rail is the exception to "a few per cent". Its six tokens mix 8–20%, where the page neutrals sit at 2–4%. At the 2.5% it started from, the derivation was real and invisible: Alternis produced #1d1f21 against a base of #1d1d1d. A rail is chrome a product wants to look like its chrome, so it takes the strongest tint that leaves both ink legs clear of the floor.
Then measure two pairs: --hab-accent-fg against your accent, and --hab-accent-text against your tint. A pair clearing AA by 0.02 is not passing; fix anything under ~4.7.
tests/contrast.spec.ts does this for the three shipped product accents and for the unbound grey, in both themes, in WCAG 2 and APCA. Add your accent to its BRAND list and the floors are enforced rather than trusted. Use APCA to break a tie: WCAG 2's relative-luminance formula under-rates white on mid-saturation blues, and it got three of six product accents backwards here.
Restating derived tokens
A token derived from a themed token must be restated in every theme scope. A var() inside a custom property resolves where it is declared, not where it is used, so a derived token left out of .dark keeps its light value whenever .dark sits on a container rather than the root. theme.css restates --hab-border, --hab-accent-text and the six tonal legs for exactly this reason.
The inverse also holds: do not restate a token to the value it already has. A matching restatement creates a silent shadow, where fixing the base gets quietly reverted by the override.
Token groups
Accent
Nine legs, of which a product binds three — --hab-accent, -fg, -on — and theme.css derives six: -hover, -text, -tint, -tonal, -tonal-hover, -ghost. Utilities are bg-accent, text-accent-on, bg-accent-tint and so on. See Binding the accent.
Surfaces
Flat: every surface is one step off the one behind it, and a surface is identified by its border, not a shadow. All of them are mixed from a few per cent of --hab-accent into a fixed grey, so they carry the brand's hue without moving their lightness.
| Token | Utility | Use for |
|---|---|---|
--hab-bg | bg-bg | The page |
--hab-surface | bg-surface | Cards, panels, inputs |
--hab-surface-2 | bg-surface-2 | Zebra rows, panel footers |
--hab-contained | bg-contained | Neutral fill (chips, badges) |
--hab-contained-2 | bg-contained-2 | Its edge, and its hover fill |
Ink
--hab-fg (primary), --hab-fg-2 (labels), --hab-fg-3 (helper, metadata), --hab-fg-disabled. As text-fg, text-fg-2, text-fg-3, text-fg-disabled.
Light ink is accent-derived like the surfaces; dark ink is not, because it is alpha rather than solid.
Light ink is solid, dark ink is alpha, and that asymmetry is deliberate: alpha text goes muddy over a tinted card in light, and reads as recessive depth on dark. Never use opacity for recession in either theme.
The palette
Nine hues — teal, lime, green, red, magenta, orange, blue, purple, gray — each in seven steps from -lighten-3 to -darken-3, with the base as the bare name (--hab-blue). gray carries an eighth, --hab-gray-darken-4. Plus --hab-light-color and --hab-dark-color. Theme-invariant: a blue is the same blue in both themes.
These are the only tokens with no Tailwind utilities, on purpose. They are runtime values: a picked color is stored as a token name and resolved with var() in an inline style, which is how HabColorPicker, HabAvatar and HabIconTile consume them. (A color that is stored and rendered outside Habitus — an exported file, a canvas — is stored as its hex instead; HabColorPicker's format="hex" exists for that.) Utilities would emit hundreds of unused classes and invite the misuse the next paragraph forbids.
A color that carries meaning the system defines — status, tone, accent — is a semantic token and not a choice. The palette is only for color that is genuinely the user's or the data's: entity kind, tag, category, avatar.
A component reaching for --hab-blue-darken-2 where --hab-info exists is a bug. --hab-hue-ink is the ink that hue-derived text is mixed toward, and it inverts per theme — a fixed dark ink measured 1.17:1 on a dark surface.
Navigation chrome
--hab-nav, -hover, -border, -fg-strong, -fg, -fg-2, as bg-nav, text-nav-fg and so on. A dark rail on both themes, going darker still on a dark page so it stays distinguishable from the surface beside it.
You do not bind these. They derive from --hab-accent like everything else, at 8–20%, so binding the accent tints the rail with the product's own hue. The one exception is --hab-nav-fg-strong, which stays pure white in both themes: it is the ink that must outrank everything on the rail, and tinting it would defeat that.
The headroom, measured across the six product accents: at 8% the tightest pair is --hab-nav-fg-2 on --hab-nav at 4.78 in light and 5.24 in dark. 15% still clears 4.5 on paper, at 4.53 — which this system's own rule calls not passing. 25% fails.
Ink is measured against the hover surface too, because that is what a row shows for as long as you point at it. --hab-nav-fg-2 does not hold there — about 4.06:1, and 4.27 even before the tint went up — which is why HabNavItem brightens a row's count to --hab-nav-fg on hover rather than leaving it. That was invisible for as long as the suite only measured ink against --hab-nav.
Dark's --hab-nav-fg-2 is rgba(255, 255, 255, 0.5), not the 0.45 the other dark ink steps use, for a reason worth knowing before you "fix" it: dark ink is alpha, so its contrast is decided by whatever it sits on, and at 0.45 it was already at the floor before the rail carried any tint at all.
A product can override --hab-nav outright — it is a plain custom property — but then it owns the contrast, and none of the pairs above apply to it any more.
Whether a product has a rail at all is a product decision — these are the parts.
Semantics
Four quartets — info, success, warning, error — each with the four legs above. error and success also carry -hover, -tonal, -tonal-hover and -ghost for buttons.
Lines
--hab-border is the edge of a surface; --hab-divider sits between repeated items inside one surface; --hab-border-strong is for unchecked marks and neutral outlined controls. As border-border-default, border-divider, border-border-strong.
Border width is a semantic axis, not a weight dial: two values, one meaning each. Do not add a third.
Control sizing
--hab-control-xs|sm|md|lg = 24 / 28 / 34 / 42px, as h-control-md and size-control-md. Every interactive component sits on this baseline so a button and an input of the same size line up.
Icons come in four sizes and no others — 16 / 18 / 20 / 24, as size-icon-sm|md|lg|xl — with one per control step: an sm control takes 16, md takes 18, lg takes 20. Never size an icon off the type ladder; that is how a system that documents four icon sizes ends up with five.
Radius
Components read --hab-radius-control (8px, rounded-control) and --hab-radius-card (10px, rounded-card) and never the ladder, so a product can re-shape every control at once. The ladder — rounded-hab-xs|sm|md|lg and rounded-pill — is there for the rare direct need.
Spacing
A 4px grid, named by multiple, which is exactly Tailwind v4's default: p-4is 16px. Everything spatial sits on it, internal padding included. The exceptions are closed and enumerated: 1px borders, 2px focus outlines, and a short list of derived control geometries documented where they occur.
Type
--hab-fs-xxs … --hab-fs-display = 11 / 12 / 13 / 14 (base) / 15 / 16 / 20 / 21 / 24 / 32 / 48, plus two role-named steps: --hab-fs-title (15px, panel and section titles) and --hab-fs-page (21px).
This ladder replaces Tailwind's rather than extending it, so text-sm is 13px here and text-base is 14px. Extending would leave text-sm meaning two different things in one codebase.
Weights are 100 / 300 / 400 / 500 / 700 / 900 — Roboto ships all six. There is no 600, so font-weight: 600 silently resolves up to Bold. Titles use medium; emphasis above 500 goes to bold. Fractional sizes are not on the ladder.
Roboto is not bundled — a library shipping font faces is wrong. Supply the face yourself; without it the stack degrades to the platform UI font, and the weights and letterforms will not match the design at small sizes.
This documentation site self-hosts Roboto through @fontsource/roboto (a devDependency, so it never reaches the package) precisely so the demos show what the design specifies rather than whatever font your platform happens to use.
Motion
Three durations (--hab-dur-hover .18s, -reveal .2s, -value .3s), one attention mark (-attention 1.6s — the whole hold-then-fade of a highlight pointing at where something just landed), one loop (-loop 1.4s) and one curve (--hab-ease-out). Line heights are 1.2 / 1.4 / 1.5 / 1.6 (tight / snug / normal / relaxed), and large type takes negative tracking — --hab-track-tight, --hab-track-snug — because Roboto needs it from about 20px up. No bounces, no springs. Buttons change opacity or fill — no translateY, no scale.
Layering
Six steps, in a fixed order, because a component that invents its own z-index eventually sits under something it should cover.
| Token | Value | Holds |
|---|---|---|
--hab-z-sticky | 100 | sticky table heads, form action bars |
--hab-z-rail | 200 | the app rail, drawers |
--hab-z-scrim | 400 | the modal backdrop |
--hab-z-modal | 410 | the dialog itself |
--hab-z-popover | 420 | menus, selects, tooltips |
--hab-z-toast | 500 | always on top |
The scrim and the dialog are separate steps: the backdrop has to sit above the rail and below its own dialog. Its color is --hab-scrim (bg-scrim), and it is theme-invariant — it darkens whatever is behind it, and a lighter scrim on a dark page reads as fog.
The popover layer sits above the dialog, which reads wrong until you look at where the elements actually are. Every overlay is portalled to <body>, so a select opened inside a modal is not inside anything — it is a sibling of the dialog sheet in the root stacking context, and a popover step below the scrim paints it behind the dialog that opened it. Ranking popovers under the scrim would only be right if a popover were a child of what it belongs to. The cost is the opposite case, a popover still open on the page behind a scrim, which a modal mostly rules out by locking pointer events and taking focus.
Tailwind v4 has no z-index namespace, so components read these directly: z-(--hab-z-modal).
The order is a stacking contract, not a dismissal one. What Esc closes is decided by reka's dismissable-layer stack, which is a runtime order — the topmost layer goes first, so a popover open inside a modal closes without taking the modal with it. A z-index cannot express that, which is why no component may hand-roll its own Esc listener.
Dialog widths
--hab-modal-sm|md|lg = 420 / 560 / 760px, as max-w-modal-md. They reach Tailwind through the --container-* namespace, which is what max-w-* reads.
Three fixed widths rather than a free width prop, and the reason is in the design source's own note: pick by content, not by taste. sm is a confirm, md holds a short form, lg is the one that needs a table or two columns. A caller passing 580px is guessing, and thirty dialogs of guessed widths is the thing a design system exists to prevent.
Theme-invariant — a dialog is not wider in the dark.
Breakpoints
--breakpoint-narrow|medium|wide = 720 / 900 / 1100px, as max-narrow: and so on. They are the three Notitia arrived at, and Core has them too: its BASICS.md states them and its tailwind.config.js defines them as screens.
- narrow is where the page gutter goes from 32px to 16px.
- medium and wide are both "a two-column page collapses". There are two because the nav rail takes 232px: a settings page beside the rail runs out of room at 1100px, and the same page on a screen with no rail runs out at 900px.
Three things differ from every other token here:
- They are literals in
@theme, with no--hab-*behind them. A media query cannot read a custom property, so Tailwind needs the value itself, and a product cannot rebind one at runtime. - They are rem, not px. Tailwind generates
max-*variants only when every breakpoint shares a unit, and its ownsm–2xlare rem. - They sit beside Tailwind's breakpoints rather than replacing them, unlike the type ladder.
HabModalusessm:, and dropping the defaults would break every app that uses them. DESIGN.md tells screens to use only these three.
Page widths and grids
The rest of Notitia's page layout, so a page writes no arbitrary width or grid. Core defines all of these too: the tokens are in its tokens.hab.css and TOKENS.md, and the utilities in its tailwind.config.js.
- Content widths:
--hab-content-form|reading|settings= 420 / 840 / 1056px, asmax-w-content-formand so on, through the--container-*namespace the dialog widths use. They cap the content by what it is; the page itself has none. - The settings nav:
--hab-section-nav(184px), asw-section-nav. - Three grid shapes:
grid-cols-cards,grid-cols-settingsandgrid-cols-overview. Tailwind v4 has no theme namespace for grid templates, so they are@utilityrules intheme.css, liketext-box-cap.grid-cols-cardsreads--hab-card-min(22rem), the narrowest a card gets before the grid drops a column.
Rows
--hab-row-h (44px) and --hab-row-h-compact (36px), as h-row and h-row-compact. A different ladder from the controls, deliberately: a row is not a control. Lists, tables and accordion headers all sit on it, so a list, a table and an accordion can stack without the page reading as three rhythms.
Markup hooks
Habitus puts a handful of data-hab-* attributes on rendered elements. They are part of the contract, not implementation detail — the stylesheet keys off two of them, so removing one silently changes how a component looks.
| Attribute | On | What it is for |
|---|---|---|
data-habitus | your app root, set by you | Opts a subtree into Habitus base styles. A library must not restyle a host's markup. |
data-hab-row | every HabNavItem | Opts the row out of the prose-link rule below, and marks a rail row for measurement. |
data-hab-brand | HabNavDrawer's brand lockup | The same opt-out, plus a stable handle on the lockup. |
data-hab-nav-toggle | HabNavDrawer's collapse control | A stable handle on the control; nothing in the CSS depends on it. |
The first two are load-bearing. base.css styles links as prose, unlayered, so those rules outrank every utility a component puts on itself:
[data-habitus] a:not([data-hab-row]):not([data-hab-brand]) {
color: var(--hab-accent-on);
}--hab-accent-on is bound for a light page, and the rail is near-black in both themes, so an anchor in the rail without one of those attributes renders in the wrong ink entirely. It has happened twice — once when HabNavItem gained href, once when the brand lockup became a link. A new component that renders an <a> must carry one of them, or the rule needs a third exclusion.
You may select on all of these from your own CSS or tests; they will not be renamed without a changeset saying so. They are not a styling API, though — reach for the tokens for that.
Focus
There is no Habitus focus ring. Native :focus-visible is the indicator: high-contrast by construction, platform-consistent, and it survives forced-colors mode. Never reset the outline on a focusable element.
base.css adds an indicator in exactly two places, each because the native one has nothing to draw on — a HabInput's box (the input inside it is borderless) and a toggle's mark (the real input is hidden at 0×0). Both depend on their class hooks (.hab-input / .hab-input-box, .hab-toggle) and on sibling order.
An earlier version of the source system shipped a 3px halo in --hab-accent-tint — a token defined as an ~8% wash — which measured about 1.1–1.3:1 against every surface in both themes. It fired on every button, option and chip in the system and was visible on none of them. Do not reintroduce it.
When components look unstyled
If a Habitus component renders with no fill, border or padding while its classes are correct, the cause is almost certainly an unlayered CSS reset in the host application.
Tailwind v4 emits real cascade layers, and Habitus utilities live in @layer utilities. An unlayered declaration outranks every cascade layer regardless of specificity, so a global button { border: 0; padding: 0; background-color: transparent } beats border-current, px-4 and bg-accent at once. Specificity is not the deciding factor here and raising it will not help.
Two fixes, in order of preference:
Wrap your reset in a layer —
@layer base { button { … } }. Layered resets lose to layered utilities, which is the correct outcome.Hand the properties back, in an unlayered rule that comes after your reset:
css[data-habitus] :is(button, [role="button"]) { border-width: revert-layer; border-color: revert-layer; padding: revert-layer; background-color: revert-layer; color: revert-layer; }revert-layerin an unlayered rule hands the property back to the last real layer, which isutilities.
The elements Habitus renders are exposed too
It is not only buttons. Components render real semantic markup because that is what assistive technology needs — a heading for an accordion header, a <ul> for a list, a <table> for a data table — and each of those is an element an application is likely to have a global rule for:
| Your rule | What it reaches |
|---|---|
ul { padding-left: 1.5rem } | HabList, HabNavDrawer, HabSubNav, HabTimeline |
h2 { margin-bottom: 1rem } | HabModal, HabSectionHeader, HabAccordion |
td { border: 1px solid } | HabDataTable |
table { display: block } | HabDataTable — stops it laying out as a table at all |
button { padding: 0 } | nineteen of the components render one |
Habitus does not defend against this, deliberately. Fix 1 or fix 2 above is yours to apply, and the reasoning is the same one that makes fix 1 preferable: a library that out-specified your stylesheet would take away your ability to override a component, which is worse than the leakage it prevents.
You do not have to restate the component's metric to do it. revert-layer hands each property back to @layer base, where Habitus's own preflight and its [data-habitus] rule have already set them — a heading's margin resolves to 0, its font-size to --hab-fs-base, its line-height to --hab-lh-snug. So the undo restores the real value on its own, and the recipe above is the whole fix.
This documentation site is itself such a host — VitePress's .vp-doc styles prose, and a demo stage is not prose. It applies fix 2 in docs/.vitepress/theme/demo.css, which is worth reading as the worked example.
Rules
- Never hard-code a color or spacing value in a component. If no token fits, the token set is missing something — say so in review rather than inlining it.
- Use the role, not the token.
text-fg-2,bg-accent-tint,h-control-md— nevertext-[13px]orbg-[#4a4756]. - Adding a token is a design decision, not an implementation detail. Open a design-change issue so the blast radius across apps gets considered.
- Contrast floors: 4.5:1 for text on any surface, 3:1 for a base color used as a border, icon or bar.
tests/contrast.spec.tsenforces them across the shipped accents in both themes — extend it rather than eyeballing a new pair.