Design & engineering

the system haus names every value

A token-first design system in five npm packages, with twenty React components and the colour science underneath.

  • Design Tokens
  • W3C DTCG
  • OKLCH
  • React
  • Storybook
  • npm

What haus is

An open-source design system, token-first and OKLCH throughout. The governing rule is that a component reads a role rather than a value, and roles resolve down to primitives. That is what makes a retheme a one-file edit and a contrast failure hard to introduce by accident. Light mode only, by decision.

It is a pnpm workspace of five packages, all on npm from one release path. haus-tokens holds the values and the roles; haus-components is the twenty React components built on it. The other three came out of the work rather than being planned for: haus-colour-utils, the colour science, extracted when the token layer needed it; haus-style-probe, lifted out of drift, which reads a rendered element's computed styles; and haus-colour-names, the 31,900-name dataset and the search over it, split out when a second project turned out to be carrying its own copy.

The token layer

Four files, in one direction. Primitives hold raw values and carry no meaning. Brand says which primitive each role takes, and nothing else. Semantics says what each role means, with no palette names in it. Motion holds the durations and curves. Components read semantics and never reference a primitive, so a theme swap replaces one file, brand.css, with no component changes. Every property carries --haus-, and the roles are declared on :root, [data-haus-theme], so a brand applies to a subtree and nests.

Every surface token has a paired on-* text token, so the contrast for that pair is fixed where the tokens are defined rather than at each call site. Every type role declares four properties: size, weight, line-height and tracking. Taking only font-size from a token and inferring the other three is how two components authored separately end up different.

--haus-color-primary-defaultwith --haus-color-ink-on-primary5.85:1AA
layerdeclaresresolves torule
primitives.css--haus-aronia-500oklch(52% 0.138 300)raw values, no meaning
brand.css--haus-brand-primary-defaultvar(--haus-aronia-500)which primitive the role takes
semantics.css--haus-color-primary-defaultvar(--haus-brand-primary-default)what the role means
componentsbackground-colorvar(--haus-color-primary-default)reads semantics only

used by Button (solid), focus ring, link ink. The ratio is computed by wcagContrast from the npm package, on the two OKLCH values this role resolves to.

A component reads a semantic. Only the primitive holds a raw value.

The ratio on the colour band is computed at render by wcagContrast, the function the npm package exports, from the two OKLCH values that role resolves to.

Success and warning do not use their family's 500 stop for solid fills. White on greengage-500 fails AA, so semantics.css declares a separate solid token at the 700, where white measures 6.82:1, and the same for mango at 7.05:1. The pairing rule surfaced that at definition time, and the fix is a token rather than a convention component authors have to remember.

/* primitives.css: raw values, no meaning */
--haus-aronia-500: oklch(52% 0.138 300);

/* brand.css: which primitive each role takes. The file a consumer replaces */
--haus-brand-primary-default: var(--haus-aronia-500);
--haus-brand-ink-on-primary:  var(--haus-damson-0);

/* semantics.css: what the role means, no raw values */
--haus-color-primary-default: var(--haus-brand-primary-default);
--haus-color-ink-on-primary:  var(--haus-brand-ink-on-primary);  /* every surface has a paired on-* */

/* components: semantics only, never a primitive */
.button.solid {
  background-color: var(--haus-color-primary-default);
  color:            var(--haus-color-ink-on-primary);
}

Because components read roles and the brand layer decides what a role resolves to, a retheme touches brand.css and nothing else. The three themes below use identical markup and identical classes; only the brand entries differ. haus ships three brands against this contract: its own, core's and vault's. The two variants below are what the architecture permits rather than what it exports.

What haus ships: aronia primary over damson neutrals.

Export tokens

Choose a format. The CSS build is the runtime artefact; the JSON is the handoff.

20 components
measured, not asserted
white on primary5.85:1AA
tertiary ink on surface3.95:1fails AA
brand.css--haus-brand-primary-default: var(--haus-aronia-500); --haus-brand-ink-secondary: var(--haus-damson-700); --haus-brand-ink-tertiary: var(--haus-damson-500);No component file changes. No primitive changes.
A retheme replaces one file. The markup and classes never change.

The high-contrast theme moves tertiary ink from 3.95:1, which fails AA, to 10.45:1, which passes AAA. It does that by pointing the role at a damson primitive that already exists. No new values are introduced.

Roles for the things that are not colour

Colour and type had roles from the start. Spacing, radius and elevation did not: components read the primitive ladder for all three, so --haus-space-3 meant padding in one file, a gap in another and a control's height in a third. The ladder cannot tell them apart, so no edit to it can either.

Spacing gets three roles over one ladder. inset is padding, the space inside a component between its edge and its content. gap is space between siblings, set by the parent. stack is margin, space a component asks for around itself. A step is the same size whichever role reads it, so the roles stay comparable, and splitting them is what lets padding be retuned later without moving page rhythm.

Billing period

Padding. The space inside a component, between its edge and its content.

--haus-space-inset-lg--haus-space-520px

padding: var(--haus-space-inset-lg);Card.module.css

The tint is painted by the property under discussion, not drawn on top.

Radius is named for what is rounded rather than for a size off the ramp: control, surface, overlay, marker, pill. Elevation is named for how high a thing sits: raised, floating, overlay. Rounding every control one step more is one edit in each case.

/* semantics.css: three roles over one ladder */
--haus-space-inset-lg:  var(--haus-space-5);   /* padding: edge to content */
--haus-space-gap-sm:    var(--haus-space-3);   /* between siblings         */
--haus-space-stack-xs:  var(--haus-space-2);   /* margin: around itself    */

/* Card.module.css: a component asks for the role */
.card    { padding: var(--haus-space-inset-lg); }

/* Radio.module.css: a size is not a role, so it reads the ladder */
.radio   { width: var(--haus-space-4); height: var(--haus-space-4); }

A radio's box is 16px because a radio is 16px, and calling that --haus-space-inset-md would say something untrue about it. So sizes read the ladder directly, and the exception is written down rather than left to be discovered: 28 declarations, all of them heights, widths, min/max bounds or transform offsets, none of them padding, gap or margin.

A test in haus-components reads every component stylesheet and asserts that no component reaches past the role layer for colour, radius, shadow or motion, that the ladder is read only for size properties, and that the number is still 28. A second count covers the reads whose primitive name is already the role, the control heights and icon sizes, and holds them at 31. The same audit found Button, Input and Select setting min-height to 28px, 36px and 44px as literals, which are neither roles nor primitives, and min-height was not in the hardcoded-value linter's list. Those are a scale of their own now, --haus-control-height-sm, -md and -lg, because 36px and 44px are not steps on the 4px spacing ladder. 44px is the WCAG 2.5.8 AAA target size.

The components

Twenty components in CSS Modules, twelve of them below: Avatar, Badge, Button, Card, Checkbox, Input, Modal, Radio, Select, Textarea, Toast and Toggle. CSS Modules keeps them independent of any consumer's build tooling. The token layer is plain custom properties, so a consuming project can use Tailwind, vanilla CSS or CSS-in-JS against it.

Badge
neutralprimaryinfosuccesswarningerror
Button
Input · Select · Textarea
Checkbox · Radio · Toggle · Avatar
Card · Toast · Modal
CardElevation comes from shadow, never from colour.
SavedTokens exported to JSON.
ModalEntry offset survives reduced motion.
Nothing here holds a raw value.

Every colour, radius, shadow and type role in the strip resolves through the semantic layer. That is what the theme swap above is moving.

The list is what an application needs before it needs anything else. Each one carries its own tests, with accessibility assertions rather than a screenshot.

One release path, five packages

Each package is released by pushing a tag shaped <directory>-v<version>. One workflow reads it, refuses to go on unless the manifest agrees with the version in the tag, checks that any workspace dependency is already on npm at the version it will be rewritten to, then builds, tests and publishes. One workflow rather than five, because five copies of it drift apart.

There is no npm token. Publishing authenticates by trusted publishing: GitHub mints a short-lived OIDC token proving which repository, workflow and commit is asking, and npm checks it against a publisher registered on the package. Nothing long-lived is stored, so there is no secret to leak or rotate, and every release carries provenance for the same reason.

The colour science

The token layer needed colour science: perceptual distance to find near-duplicate tokens, WCAG ratios to check each paired surface, a lightness ramp to generate palettes, a nearest-name search for labelling. None of it is specific to haus and none of it needs React, so it was extracted into haus-colour-utils and published. It has four consumers, more than any other package here.

Eleven functions, pure ESM, its own type declarations, one runtime dependency. Seven of them below.

// npm install haus-colour-utils
import {
  deltaE,                        // CIEDE2000 perceptual distance
  wcagContrast,                  // ratio + AA / AAA / AA-large verdicts
  clusterByPerceptualDistance,   // group near-duplicate colours
  nearestNamedColour,            // two-pass CIE76 → CIEDE2000 name search
  generateLightnessScale,        // perceptual ramp in LCH
  isLight, suggestTextColour,    // readable-text helpers
} from 'haus-colour-utils'

deltaE("#3366cc", "#3467cc")     // 0.34, effectively the same colour

Four of them run below, on the package's own algorithms.

clusterByPerceptualDistance(hexes, threshold?): ColourCluster[]
threshold
ΔE 8
result4 groups of near-duplicates
    • ★
    #ebeaed3 within ΔE 8
    • ★
    #6b3f8f3 within ΔE 8
    • ★
    #3366cc3 within ΔE 8
    • ★
    #2f9e6b3 within ΔE 8

Twelve token values in, grouped by single-linkage union-find. Drag the threshold and watch groups merge: the twelve collapse to three by ΔE 12. The starred swatch is the member nearest the group's Lab centroid, and its hex is printed under each group, because that is the value a de-duplication pass keeps.

Every number here is computed.

clusterByPerceptualDistance groups colours by single linkage: any member within the threshold pulls a new colour into the cluster, implemented as a union-find over every pair. The representative it returns is the member nearest the group's Lab centroid rather than the first one seen, so a de-duplication pass keeps the most typical value in the group.

// packages/colour-utils/src/cluster.ts
for (let i = 0; i < unique.length; i++) {
  for (let j = i + 1; j < unique.length; j++) {
    if (chroma.deltaE(unique[i], unique[j]) < threshold) {
      union(i, j)          // any member within threshold pulls the colour in
    }
  }
}

// the kept swatch is the member nearest the group’s Lab centroid
return [...groups.values()]
  .map(members => ({ representative: centroid(members), members, size: members.length }))
  .sort((a, b) => b.size - a.size)

nearestNamedColour runs two passes, as the naming search in Vault does. CIEDE2000 is accurate and expensive, so a Euclidean CIE76 scan first narrows the dataset to candidates within a fixed radius, and only those are re-scored with CIEDE2000. Its 31,900-name dataset is its own package now, haus-colour-names, because Vault was carrying a 764KB copy of the same file and its own copy of the same two-pass search.

ESM and CommonJS. The package once shipped ESM alone with main pointing at it, so a bundler falling back to main in a CommonJS context loaded ESM and failed on the import statement. The packages hold no module state, so two copies behave identically. Types are generated at build time, so there is no separate @types package to keep in step. chroma-js is left external rather than bundled, so a consumer that already depends on it does not ship a second copy.

// tsup.config.ts
export default defineConfig({
  entry: ['src/index.ts'],
  format: ['esm', 'cjs'],  // main pointed at ESM, so a CJS fallback failed
  dts: true,                // types ship with it, no @types package to maintain
  treeshake: true,
})

// package.json
"type": "module", "sideEffects": false,
"main": "./dist/index.cjs", "module": "./dist/index.js",
"files": ["dist"], "dependencies": { "chroma-js": "^3.1.2" }

What consumes it

Four projects install haus-colour-utils by name and import only what they need. hexicon uses deltaE for colour-difference work and wcagContrast in its palette analyser. drift uses both and adds clusterByPerceptualDistance, which is the function that lets it find near-duplicate colour tokens in a shipped stylesheet, and hueFamily and oklch. Vault takes hueFamily and, from haus-colour-names, the dataset it used to commit. All three delegate rather than reimplement, and each says so in a comment at the import.

core installs haus-tokens under its own brand and thirteen of the twenty haus-components. Vault installs haus-tokens for its token layer. drift installs the colour maths and the probe. Its client also used haus-tokens and two components, and removed both on 2026-09-09.

hauspnpm workspace5 packageshaus-tokenscss · W3C json · typed jshaus-components20 components · css moduleshaus-colour-utilscolour science · no reacthaus-style-probecomputed styles, normalisedhaus-colour-names31,900 namesnpmtrusted publishingcoretokens · componentshexicondeltaE · contrastdrift+ cluster · probevaulttokens · hue · names

One pnpm workspace, five packages.

Design decisions

OKLCH over hex and HSL. Equal numeric steps in L, C or H produce equal changes to the eye, so ramps can be authored by reasoning about perception rather than by adjusting hex values and checking. Wide-gamut P3 support follows from the colour space. Conversion to hex is a display concern, not the source of truth.

Role-based type with no h1 to h6. The scale is display, heading, body, label and mono, in size variants. Decoupling visual hierarchy from document semantics prevents an h1 style being used on decorative text to obtain a large size.

W3C Design Tokens JSON as the export format. The CSS custom properties are the runtime format; tokens.json conforms to the DTCG 1.0 spec and is the handoff format, so Style Dictionary or any spec-reading pipeline can consume haus with no haus-specific tooling in between.

Reduced motion overrides duration, not the transition. Some transforms carry positional meaning, like the Toggle thumb and the Modal entry offset. A control that snaps instantly to its new position still communicates state; one that does not move at all is ambiguous. So the policy sets --haus-duration-reduced rather than transition: none. It is a named token rather than a literal 0ms, so the value stays tunable in one place.

Light mode only. haus ships one theme. Doing dark mode properly means auditing every semantic token for contrast against a second set of surfaces, which doubles the colour decision surface, and the system is scoped to prove the light structure without that cost. Because components read roles, a dark theme would be a second semantics.css rather than a redesign, so it is deferred rather than designed out.

The token guard ships with the tokens. A var() naming an undefined property drops the declaration with no error. drift wrote a check for that and it found five missing roles. The contract is defined in haus-tokens, so the check moved there as haus-tokens/guard rather than being written again by each consumer.

The full decisions, with alternatives and the four-property rule, in DESIGN.md →