Design & engineering · 2026

subject to change · some content here is out of date

the system haus publishes

Three packages of a token-first design system, one of them on npm, with the colour science underneath both the tokens and the components.

  • 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 never reads a raw value, only a role, 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. It is a pnpm workspace holding three packages: the token layer, twelve React components built on it, and the colour science underneath both. The first two are internal to the workspace; the third is published to npm and has two consumers outside this repository. Light mode only, by decision.

The token layer

Three layers, with a rule between each. Primitives hold raw values and carry no meaning. Semantics alias primitives under role names and contain no raw values. Components read semantics and never reference a primitive. A theme swap is therefore an edit to one file, semantics.css, with no component changes.

Two further constraints. 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.

--color-primary-defaultwith --color-ink-on-aronia5.85:1AA
layerdeclaresresolves torule
primitives.css--aronia-500oklch(52% 0.138 300)raw values, no meaning
semantics.css--color-primary-defaultvar(--aronia-500)intent, no raw values
componentsbackground-colorvar(--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 measures 3.96:1 and fails AA, so semantics.css declares a separate solid token at the 700, where white measures 6.96: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 */
--aronia-500: oklch(52% 0.138 300);

/* semantics.css — intent, no raw values */
--color-primary-default: var(--aronia-500);
--color-ink-on-aronia:   var(--damson-0);   /* every surface has a paired on-* */

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

Because components read roles and roles alias primitives, a retheme touches semantics.css and nothing else. The three themes below use identical markup and identical classes; only the semantic custom properties differ. haus ships the first. The other two are permitted by the architecture but are not part of the system.

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.

12 components
measured, not asserted
white on primary5.85:1AA
tertiary ink on surface3.95:1fails AA
semantics.css--color-primary-default: var(--aronia-500); --color-ink-secondary: var(--damson-700); --color-ink-tertiary: var(--damson-500);No component file changes. No primitive changes.
A retheme edits 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.

The components

Twelve components in CSS Modules: 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.

haus-colour-utils on npm

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 its own package and published.

Seven exports, pure ESM, its own type declarations, one runtime dependency.

// 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.7, effectively the same colour

Four of the seven 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.

The build config is short. ESM only, with no CommonJS output, because nothing consuming the package uses require(). 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'],   // no CJS: the package is new, nothing needs require()
  dts: true,         // types ship with it, no @types package to maintain
  treeshake: true,
})

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

What consumes it

Two projects install the package 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. Both delegate rather than reimplement, and both say so in a comment at the import.

loom is the other kind of consumer. It will take haus as a whole system rather than one package: an MCP server and agent that detects a design system drifting, with Figma as the intent and shipped CSS as the reality. It is still in build, so the diagram draws that dependency dashed.

hauspnpm workspace3 packageshaus-tokensinternal · css + W3C jsonhaus-componentsinternal · 12 componentshaus-colour-utilspublished · no reactnpmv0.1.1 · MIThexicondeltaE · contrastdrift+ clusterloomwhole system

One pnpm workspace, three 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 --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, not two. A single polished light theme was worth more than two adequate ones, and because components read semantic aliases, a dark theme is mostly a second semantics.css rather than a redesign: deferred, not designed out. The same restraint holds for what ships. Only haus-colour-utils is published; the tokens and components stay internal, so the system's reach outside this repository is the one package with real algorithms in it.

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