@hanzo/theming
v7.3.1
Published
Theme engine, palette generation, and themed components for Hanzo-powered apps
Keywords
Readme
@hanzo/theming
A theming and component toolkit for building white-label apps using hanzogui.
It also provides generation of Tailwind color tokens expected by shadcn for compatibility with systems that use @hanzo/ui.
How it works
Every color in the system — backgrounds, text, borders, button states — is derived from a 12-step palette, and sometimes an additional neutral palette. These can be configured with a simple one-color "seed." Doing so generates both light and dark versions of the palette. Hanzogui calls these palettes "themes," and our system uses 7 of them: neutral, primary, secondary, info, success, warning, and danger. These themes can be used with all @hanzogui components, as well as all componenents in @hanzo/theming/themed-components.
They are also translated into a Tailwind CSS theme that can be used with systems based on @hanzo/ui (which uses shadcn).
Typically, org-specific brand packages (@my-org/brand) just provide seeds (or 12-step palettes) and assets, and then invoke the generation logic that lives here.
Install
pnpm add @hanzo/themingQuick start
Hanzogui config and org's brand module
In the org's brand module — assemble and export a Hanzogui config from your seeds:
// @my-org/brand/src/hanzogui-config.ts
import { createHanzoguiConfig } from '@hanzo/theming/hanzogui-config'
import brandJson from './brand.json'
export const hanzoguiConfig = createHanzoguiConfig({
themes: brandJson.themes,
// omitted fields (fonts, size, space) fall back to defaults
})In the org's apps — wrap the app with HanzoguiProvider:
// @my-org/<app>/src/main.tsx
import { hanzoguiConfig } from '@my-org/brand'
import { HanzoguiProvider } from 'hanzogui'
<HanzoguiProvider config={hanzoguiConfig}>
<App />
</HanzoguiProvider>Tailwind / shadcn
For use with shadcn-based code, token mappings from Hanzogui 'themes' (palettes) are provided by @hanzo/theming. Only the palettes are org-specific, and they are generated at build time by the generate-palettes CLI (see For org brand packages).
For convenience, an org's brand package should likely export a single CSS file that assembles the pieces in the right order.
// App.tsx
@import '@my-org/brand/tw-my-org.css';This file would contain...
@import 'tailwindcss';
@import './my-org-tw-additions.css'; /* (optional) org-specific non-color tokens */
@import './brand-palettes.css'; /* --color-{theme}-{1..12}, generated by prebuild*/
@import '@hanzo/theming/shadcn-semantic-tw-colors.css'; /* shadcn semantic refs derived from the above file */NOTE: Order matters here. brand-palettes.css define the vars, shadcn-semantic-tw-colors.css references and maps them for shadcn.
Palette system
Follows the Radix Colors semantic scale. Every seed produces a 12-step palette with fixed semantic roles:
| Step | Role | Token |
|------|------|-------|
| 1 | App background | $color1 / $grey1 |
| 2 | Subtle background | $color2 / $grey2 |
| 3 | UI element background | $color3 / $grey3 |
| 4 | Hovered UI element bg | $color4 / $grey4 |
| 5 | Active / pressed UI bg | $color5 / $grey5 |
| 6 | Subtle borders | $color6 / $grey6 |
| 7 | UI borders / focus rings | $color7 / $grey7 |
| 8 | Hovered borders | $color8 / $grey8 |
| 9 | Solid background (= seed) | $color9 / $grey9 |
| 10 | Hovered solid | $color10 / $grey10 |
| 11 | Low-contrast text | $color11 / $grey11 |
| 12 | High-contrast text | $color12 / $grey12 |
Two palette types
Neutral palette — a grey ramp generated from the neutral seed. Injected into every Hanzogui theme as $grey1–$grey12. Used for surfaces, borders, and body text regardless of which accent theme is active.
Accent palette — a full 12-step scale from an accent seed (primary, danger, etc.). Steps 1–8 use the accent hue at progressively increasing saturation; step 9 is the literal seed; steps 10–12 blend toward the scheme foreground. Mapped to $color1–$color12 by Hanzogui's default template.
An additional $solidText token is computed per-theme based on the luminance of step 9 — white text for dark fills, dark text for light fills.
Component convention
Within any <Theme> wrapper, both palettes are available:
$grey— surfaces, borders, body text (neutral)$color— accent fills, hover, press, accent-colored text$solidText— text on solid action surfaces (auto-contrasted)
<Theme name="primary">
{/* Card surface — grey */}
<YStack bg="$grey2" bc="$grey7">
<Text color="$grey12">Body text</Text>
{/* Button — accent */}
<XStack bg="$color9" hoverStyle={{ bg: '$color10' }}>
<Text color="$solidText">Submit</Text>
</XStack>
</YStack>
</Theme>This means a single <Theme name="primary"> gives components access to both neutral surfaces and vivid accent fills — no nested theme wrappers needed.
Theme seeds
Seven named themes, all optional. Omitted themes get these defaults:
| Theme | Default | Purpose |
|-------|---------|---------|
| neutral | #808080 | Greyscale canvas — backgrounds, text, borders |
| primary | #3B82F6 | Primary actions — buttons, links, focus rings |
| secondary | #721be4 | Secondary accent |
| info | #eab308 | Informational callouts, tips |
| success | #16a34a | Success states, confirmations |
| warning | #fb923c | Warning states, caution |
| danger | #dc2626 | Errors, destructive actions |
Each theme can be specified as:
// A seed — generates both light and dark palettes
{ seed: '#1a2744' }
// An explicit 12-step palette — used for both schemes (dark is reversed)
['#f0f3f8', '#dfe5f0', ..., '#0c1322']
// Per-scheme — mix seeds and explicit palettes
{ light: { seed: '#1a2744' }, dark: ['#0c1322', '#131d34', ..., '#f0f3f8'] }Hanzogui config
createHanzoguiConfig() produces a complete Hanzogui config from your supplied values:
import { createHanzoguiConfig } from '@hanzo/theming/hanzogui-config'
const config = createHanzoguiConfig({
themes: {
primary: { seed: '#1a2744' },
// neutral, secondary, info, success, warning, danger — pick up defaults
},
fonts: {
body: { family: '"Inter", sans-serif', size: { ... }, ... },
heading: { family: '"Inter", sans-serif', size: { ... }, ... },
mono: { family: '"JetBrains Mono", monospace', size: { ... }, ... },
},
// pick up defaults for 'size' and 'space'
})| Option | Type | Default |
|--------|------|---------|
| themes | ThemesConfig | Default color seeds for the 7 themes specified above |
| fonts.body | FontDef | System sans-serif stack |
| fonts.heading | FontDef | System sans-serif stack |
| fonts.mono | FontDef | System monospace stack |
| size | Record<string, number> | Non-linear component size scale (20–144px) |
| space | Record<string, number> | Linear 4px grid (0–96px) |
Each theme seed becomes a Hanzogui children theme with both $color (accent) and $grey (neutral) tokens available:
<Theme name="primary">
{/* Grey for surfaces, accent for actions */}
<YStack bg="$grey2" bc="$grey7">
<Button bg="$color9">Submit</Button>
</YStack>
</Theme>
<Theme name="danger">
{/* Accent-tinted surface for alerts */}
<StatusBox bg="$color2" bc="$color6">
<Text color="$color11">Error occurred</Text>
</StatusBox>
</Theme>Light/dark is handled structurally — the active scheme is inherited from the root, and each children theme has both variants.
Tailwind / shadcn details
Brand palettes as brand-palettes.css
auto-generated within an org's
<org>/brandfrom itsThemesConfig(which generally lives inbrand.json)generateTwThemePalettesCss(config?): Produces--color-{theme}-{1..12}for all 7 themes/palettes in:rootand[data-color-scheme='dark'].
Shadcn semantic colors are derived in shadcn-semantic-tw-colors.css
@hanzo/theming/shadcn-semantic-tw-colors.css— Derives shadcn's expected color tokens (--background,--primary,--destructive...) from the palettes as css vars (var(--color-neutral-1),var(--color-primary-10)...) that were generated from the org's ThemesConfig. Simply maps, so does not vary. Just needs to be included in the app's css file afterbrand-palettes.css.
The generate-palettes CLI
@hanzo/theming includes a generate-palettes CLI that reads an org's brand.json and writes the palettes CSS file:
# Defaults: reads src/brand.json, writes src/brand-palettes.css
npx generate-palettes
# Custom paths
npx generate-palettes --brandFile src/our-brand.json --outFile src/our-brand-palettes.cssNormally this is not run manually, but as a prebuild step in an org brand package's package.json:
"prebuild": "generate-palettes"Output: brand-palettes.css
:root {
--color-neutral-1: #fafafa;
--color-neutral-2: #f5f5f5;
/* ... through 12, for all 7 themes */
}
[data-color-scheme='dark'] {
--color-neutral-1: #0d0d0d;
/* ... */
}Components
Themed Hanzogui components: ThemedButton, GhostButton, OutlineButton, StatusBox, ToggleSwitch, StyledCard.
See COMPONENTS.md for usage and examples.
Package structure
src/
├── types.ts # Palette12, ThemeSeed, ThemeDesc, ThemesConfig
├── palette-utils.ts # generateNeutralPalette, generateAccentPalette, resolveThemeDesc
├── hanzogui/ # Hanzogui-specific
│ ├── types.ts # FontDef, HanzoguiConfigOptions
│ ├── create-config.ts # createHanzoguiConfig(options?)
│ ├── index.ts # (internal barrel)
│ ├── defaults/
│ │ ├── themes.ts # default seed colors
│ │ ├── spacing.ts # default size + space scales
│ │ ├── fonts.ts # default system font definitions
│ │ └── index.ts # barrel (seeds, size, space, fonts)
│ └── components/ # see COMPONENTS.md
└── hanzo-ui/ # Tailwind / shadcn
├── utils.ts # generateTwThemePalettesCss
├── generate-palettes.ts # CLI: reads brand.json, writes brand-palettes.css
├── index.ts # (internal barrel)
└── shadcn-semantic-colors.css # static — shadcn semantic color mappingsExport paths
| Path | What |
|------|------|
| @hanzo/theming/hanzogui-config | createHanzoguiConfig and types |
| @hanzo/theming/themed-components | Hanzogui components |
| @hanzo/theming/hanzogui-config-defaults | Defaults (seeds, size, space, fonts) |
| @hanzo/theming/shadcn-semantic-tw-colors.css | Static CSS — maps 12-step palettes to semantic color values expected by shadcn |
Example use in @my-org/brand
An org brand package depends on @hanzo/theming and provides just data:
@my-org/brand/
├── src/
│ ├── brand.json # org identity, URLs, and Hanzogui palettes in 'themes' field
│ ├── brand-palettes.css # generated by prebuild (gitignored)
│ ├── my-org-tw-additions.css # (optional) org-specific tw tokens
│ ├── tw-my-org.css # bundle: tailwind + additions + palettes + semantic
│ ├── fonts.ts # (optional) org-specific font defs
│ ├── hanzogui-config.ts # calls createHanzoguiConfig
│ ├── types.ts # BrandIdentity, OrgConfig (define the rest of brand.json)
│ └── index.ts # re-exports
└── assets/ # logos, etcThe hanzogui-config.ts calls createHanzoguiConfig with the org's seeds and fonts:
import { createHanzoguiConfig } from '@hanzo/theming/hanzogui-config'
import brandJson from './brand.json'
import { bodyFont, headingFont, monoFont } from './fonts'
export const hanzoguiConfig = createHanzoguiConfig({
themes: brandJson.themes,
fonts: { body: bodyFont, heading: headingFont, mono: monoFont },
})The package.json has a prebuild script that runs the palette generator:
"scripts": {
"prebuild": "generate-palettes",
"build": "tsup src/index.ts --format cjs,esm --dts --clean"
}generate-palettes is included in @hanzo/theming as a bin. By default it reads src/brand.json and writes src/brand-palettes.css.
