0.4.8 stopped the generic ring from reaching fields, which fixed the double indicator and quietly created a worse defect: measured on --background, the brightened edge it left behind is 1.91:1 and the halo 1.25:1, against the 3:1 this package already gates --ring on. The accidental second ring had been the only reason a focused field was visible at all. So the field rule goes entirely and the ring covers everything. Two reasons, and the second is the one that generalizes: 1. Contrast. 2px at --ring is 3.77:1 and clears the 2px perimeter WCAG 2.4.13 asks for. The budget was documented as spent entirely on --ring "because that is what a keyboard user navigates by" — which was true for a button and false for a field for exactly as long as the field rule told fields not to use it. Now it is true for both. 2. Suppressibility. The field treatment carried its indicator on border-color, so any app that states `border` on its own fields overrode it. @hanzo/id does, unlayered, which beats a layer whatever its specificity — its focused fields sat at the resting .15 while both stylesheets read as correct. An outline is not a border: no app's field styling reaches it, so the ring paints whether or not the app has opinions about edges. check-tokens now enforces the property rather than the ordering: exactly one rule may paint a focus outline, none may set outline:none, none may add a box-shadow on focus. It reports both defects against the pre-0.4.9 shape. --border-focus and --ring-focus stay declared and exported — consumers use them — but nothing in the token layer spends them on focus any more. Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Hanzo Design System
Monochrome. Dark by default. One hue rendered through an opacity ladder.
The single source of truth for how every Hanzo surface looks — tokens, components, brand assets, and the guidelines that hold them together.
@hanzo/design
If you are an agent
Read skills/design-system/SKILL.md. It is the
one way an AI surface learns this design system — chat, hanzo.app and Claude
Code all load the same file, so there is a single author and nothing to drift.
It ends by running the gate below, which is what makes the rules enforceable
rather than advisory:
npx hanzo-design-lint <paths…>
prompts/ holds the longer material the skill points at. It is reference, not a
second entry point.
Use it
One import pulls in the whole token layer — fonts, colour, type, spacing, radius, elevation, motion, z, base:
@import "@hanzo/design/styles.css";
Import the whole file. Cherry-picking tokens/*.css is how surfaces ended up
without the z ladder or the elevation ramp. The fonts are self-hosted, so this
makes no third-party request — there is no longer a reason to skip a part of it.
Programmatic tokens (control look & feel from code)
The same tokens are exposed to TypeScript — generated from the CSS, so the two
can never drift. Edit a token in tokens/*.css, run npm run build, and both the
stylesheet and the code API update together. This is the one place to drive Hanzo's
look & feel programmatically (the @hanzogui/shell theme, Tamagui, any TS surface):
import { colors, spacing, radius, zIndex, cssVar } from '@hanzo/design'
spacing['space-4'] // "1rem"
radius['radius-full'] // "9999px"
zIndex['z-header'] // 300
// Prefer cssVar() so a value resolves through the live cascade (honors light/dark):
element.style.background = cssVar('--background') // "var(--background)"
element.style.color = cssVar('--foreground', '#fff') // with a fallback
Groups: colors, typography, spacing, radius, elevation, motion, zIndex,
fonts, base (semantic aliases), plus cssVars (every token by its literal
--name). All authored once in the token CSS — the single source of truth.
Everything below is expressed as CSS custom properties, so code copies over 1:1 — the semantic names match hanzo.ai's variables exactly.
import { Button } from "@hanzo/design/components/core/Button.jsx";
import { HanzoLogo } from "@hanzo/design/components/core/HanzoLogo.jsx";
<Button pill>Try Hanzo</Button>
What's inside
| Path | What |
|---|---|
styles.css |
The one entry point — imports every token file below. |
tokens/ |
The palette. colors (monochrome opacity ladder, dark-default), typography, spacing, radius, elevation, motion, z, fonts, base. |
components/ |
core (Button, Card, Badge, Icon, HanzoLogo, Avatar, ChromeText…), forms, overlays, navigation — each as .jsx + .d.ts + a .prompt.md usage guide. |
prompts/ |
System-level generation guidance — the "make it look Hanzo" system prompt, do/don't rules, page prompts. |
content/ |
The words — brand voice and taglines. |
docs/ |
How to use the system — integrate the tokens, theme, extend. |
guidelines/ |
Specimen cards — color, type, spacing, brand, iconography — the visual reference. |
assets/fonts/ |
Geist Sans + Geist Mono, self-hosted (two variable .woff2, 141 KB, SIL OFL-1.1). No Google Fonts request. |
assets/ |
The mark, wordmark, favicon, provider + partner logos, brand imagery. |
scripts/check-tokens.mjs |
The gate: every token file is served, every internal var() resolves, and the contrast floors hold. Runs on npm run build. |
ui_kits/ |
Composed surfaces (e.g. SiteChrome) assembled from the components. |
Two kinds of boundary
Not interchangeable, and the difference is a conformance requirement rather than a matter of taste:
| Token | Kind | Contrast |
|---|---|---|
--border, --border-hairline, --border-card |
decorative — separates content | none required |
--border-strong |
perceivable — identifies a control (input edge, switch, checkbox) | ≥ 3:1, WCAG 1.4.11 |
--ring |
the focus indicator | ≥ 3:1, WCAG 2.4.11 |
Reach for --border-strong whenever the boundary is the affordance. Both
conformant tokens resolve to --neutral-500, the only rung on this ladder that
clears 3:1 on every surface in both themes — scripts/check-tokens.mjs
measures this on every build and fails if it ever stops being true.
Note that the --white-* opacity ladder does not invert in light theme, so
--white-40 is white-on-white there. Anything needing a visible edge in both
themes must use --border-strong.
Principles
- Monochrome by construction — one neutral ladder plus an opacity ladder is the entire palette. Color appears only as genuine semantics (live/error/warning).
- Dark is the default theme — surfaces mount dark-first; light is the override.
- A token that is referenced must resolve — an undefined custom property paints
nothing, silently.
cssVar()therefore accepts only real token names (a typo is a compile error) and emits the authored literal as a fallback, so a reference still paints on a host that has not loaded the CSS. - White-label by fork —
@luxfi/designand@zooai/designcarry the same token names over their own values. A change here must be measured against all three before it ships; one that fixes Hanzo and breaks Zoo is a regression. - Self-contained components — inline styles, no CSS-framework coupling, so a component drops into any host (Next, Vite, Tamagui, none) and renders identically.
- Every component ships its own
.prompt.md— a one-screen usage guide for humans and AI alike.
Source of truth
Tokens track hanzo.ai (app/globals.css, tailwind.config.ts, DESIGN.md) and the press kit. When the brand moves, it moves here first.
License
MIT OR Apache-2.0, per HIP-0137. Brand marks (the Hanzo logo, partner and provider logos) are the property of their respective owners and are provided for identification.
Using it in a Tailwind app
One import, after Tailwind's:
@import "tailwindcss";
@import "@hanzo/design/tailwind.css";
That carries the tokens and maps them onto Tailwind's namespace, so
bg-background, text-muted-foreground and border-border resolve to the
system's values. There is no @theme block to hand-write and keep in sync.
Not using Tailwind? @import "@hanzo/design/styles.css" is the same tokens
without the bridge.
Two rules, and the build enforces both
- Import it after
tailwindcss.@themeextends the framework, so it has to follow it. - Don't re-declare tokens in your app. An app that states its own palette
drifts from every other surface, and any token it forgets resolves to nothing
—
border-borderbecomescurrentColor, which paints a white hairline on black and reads as a design choice rather than a missing line.