Config & CLI
An optional file. hyzer.config.ts describes your design system once. The hyzer CLI turns it into a token sheet, a trimmed icon barrel, and an optional utility
sheet. Every run checks the result against WCAG AA. Skip the file and you get the defaults.
The config file and the CLI are both optional
Describe your system in one file
The same engine that generates this library's own tokens.css ships in the
package. Describe your system once in hyzer.config.ts. Overrides merge over the
base schema, and new keys extend it. Nested tokens.palette objects generate ramps
(--hz-palette-brand-red-900) even though the base palette ships none.
import { defineConfig } from '@hyzer-labs/ui/config';
export default defineConfig({
output: 'src/styles/tokens.css',
defaultThemeName: 'brand', // names the tokens block below; default 'default'
// Everything under `tokens` is the DEFAULT theme: it lands in the
// :root block, which is what a page gets with no data-theme set.
tokens: {
palette: {
primary: '#0f766e', // override
fairway: '#3f6212', // add a hue
brandRed: { 50: '#fef2f2', 500: '#b91c1c', 900: '#7f1d1d' } // add a ramp
},
intent: {
fairway: 'var(--hz-palette-fairway)', // add an intent
// Intents are graded, so aim at a ramp's middle. Point one at
// the pale or dark end and the report fails that pairing.
brand: 'var(--hz-palette-brand-red-500)'
},
typography: { fontFamily: { sans: "'Inter', system-ui, sans-serif" } },
density: { unit: '0.5rem' },
// Per-component theme hooks, camelCased, no --hz- prefix. These are
// the custom properties each component page lists under Theme hooks.
components: { buttonAccent: 'var(--hz-intent-secondary)', badgeTint: '20%' }
},
// Named variants that override the default, keyed by data-theme.
// A theme takes any group `tokens` takes, not only color.
themes: {
dark: {
palette: { primary: '#2dd4bf', fairway: '#a3e635' }
},
print: { typography: { fontSize: { base: '0.9rem' } }, radius: { md: '0' } }
}
});defaultThemeName names the theme above rather than overriding any of its tokens.
It has no command-line flag, because a theme's name describes your system, not one run. Set it
to 'light' to get back the block earlier versions of this library shipped: that
is the one-line migration if you are upgrading. Dark has no matching key, because dark is the platform's own name rather than this library's, so it stays fixed.
See Define your themes for the reasoning, and for
how to change dark itself.
components reaches the per-component custom properties too: the same knobs each
component page lists under Theme hooks, such as a button's accent color or a
badge's tint strength. Set one here and the generator writes the rule on that component's own
class, once, for the whole system. You maintain no CSS override of your own.
Hooks belong under tokens only. A named theme cannot carry them, so point a hook at
a token when you want its value to change per theme.
Every run prints a WCAG contrast report over the resolved tokens. It uses the same math and the same pairings that validate this library's own token set. It covers your custom intents too:
$ hyzer generate
config: hyzer.config.ts
wrote src/styles/tokens.css (full, 89 tokens)
contrast: 104 pairings checked, all pass WCAG AAWrite your colors in any CSS color space. The report grades oklch(), hsl(), lab(), color(display-p3 …), a named color and a
hex alike. Each one reaches the sheet exactly as you wrote it.
color-mix() is read too, in all fifteen spaces CSS can interpolate in, hue
direction included. A longer hue mix grades as the color it paints.
Relative colors work too, arithmetic included: oklch(from var(--brand) calc(l * 0.8) c h) is graded as the color it computes to.
Some values cannot be graded on their own: a currentColor, a color with alpha, or
a var() pointing outside your config. The report names each one and skips its pairings.
If the value is a surface or the text role, its whole theme goes ungraded, and that counts as a
failure. A palette the gate could not read never reports as passing.
WCAG is defined on sRGB, so a color outside that gamut is graded on its clipped equivalent, and the report names those pairings. The ratio is the right call for an sRGB screen. It is not measuring what a wide-gamut display paints.
Aim intents at the middle of a ramp
--strict then fails the whole run: it is all-or-nothing and cannot be narrowed to
certain tokens. Point intents at the middle of a ramp, as brand does above, or run
without the flag and read the warnings yourself.You choose what it writes. The flags compose:
# A complete sheet: import it INSTEAD of tokens.css:
hyzer generate
# A patch sheet with only your overrides: import it AFTER tokens.css:
hyzer generate --mode overrides
# Also write the opt-in utilities sheet, next to the tokens sheet:
hyzer generate --utilities
# Flags compose: a patch sheet AND the utilities sheet, one run:
hyzer generate --mode overrides --utilities
# A sheet scoped to a class, for a region with its own palette:
hyzer generate --mode overrides --selector .theme-ocean
# Validate without writing; fail CI on a contrast miss, an unknown icon,
# or a committed sheet that's fallen behind the config:
hyzer generate --check --strict"Out of date" means the file on disk is not what your config would produce right now. A library upgrade counts too: new token values or comment text leave a committed sheet stale until you regenerate it.
--check expects the sheet to have been written with the same --mode you are checking with, so a mode mismatch is reported by name instead of as
a wall of differences. A file you never committed is reported as not generated. It does not fail
the build, so generating the sheet at build time instead of committing it stays a supported workflow.
TypeScript configs need Node 22.18
hyzer.config.mjs instead. You can also import the engine straight from @hyzer-labs/ui/config (resolveConfig, generateCss, contrastReport) if you'd rather drive it from your own script than use the CLI.Command-line flags
The flags and the config file cover different ground, on purpose. A flag that describes a
single run has no config key: --config, --mode, --check and --help. Everything that describes your design system
lives in the config instead, such as tokens, themes, defaultThemeName, icons and contrast. Four flags have
both (--out, --utilities, --strict and --selector), so one run can
override the file, with the flag winning.
| Flag | Config key | What it does |
|---|---|---|
--config <path> | — | Which config file to read. Defaults to hyzer.config.ts, .js or .mjs in the current directory. |
--out <path> | output | Where the token sheet is written. The config key is relative to the config file. The flag is relative to where you run the command, and wins when both are set. With neither set, the sheet lands beside your config, or in the directory you ran from if no config is found. The utilities sheet follows it, unless utilities.output names a path of its own. |
--mode <mode> | — | "full" (the default) writes a complete sheet that replaces tokens.css. "overrides" writes a patch sheet to import after it. |
--selector <selector> | selector | Where the generated sheet is rooted. Defaults to :root. A class or an id scopes the whole sheet to that element and everything inside it. The flag wins over the config key. |
--utilities | utilities | Also write the utilities sheet. It turns the sheet on even when the config does not. A path set in the config is still used. |
--check | — | Resolve and report without writing any files: no token sheet, no utilities sheet, no icons.ts. It also compares the files already on disk to what this run would write. A committed sheet that has fallen behind your config is reported. Pairs with --strict for a CI check that touches nothing. |
--strict | strict | Exit non-zero if any pairing misses the contrast bar, any icon name is unknown, or a checked file is out of date. A file that was never generated is reported but does not fail the build. Without --strict, all three are warnings and the run succeeds. The strict config key does the same; the flag turns it on even when the config does not. |
--help | — | Print the usage summary this table is drawn from. |
Trim the icon set
The config reaches icons too: an optional icons: string[] list of kebab-case Lucide names. hyzer generate emits an icons.ts module next to the tokens sheet. It re-exports each icon by name from the @hyzer-labs/ui/icons/<name> deep paths, covering your list plus the library's always-shipped core set (the chevrons, close,
menu, and the rest its own components depend on). Your app's autocomplete then offers your own icon
vocabulary rather than the full 1,700-plus Lucide names.
import { defineConfig } from '@hyzer-labs/ui/config';
export default defineConfig({
// kebab-case Lucide names: 'plus' is already core (deduped, no warning)
icons: ['plus', 'trash-2', 'settings', 'serch']
});List a core icon explicitly and it is deduplicated into the core group with no warning. An
unknown name is a report warning by default; --strict turns it into a failing
run. Either way the icon is left out of the emitted barrel. Omit the icons key
and hyzer generate writes no icons.ts at all and skips that report section. icons: [] behaves differently: the empty array is a valid, minimal config that does
write the file, holding the core set on its own.
$ hyzer generate
wrote hyzer-tokens.css (full, 84 tokens)
wrote icons.ts (16 icons)
contrast: 92 pairings checked, all pass WCAG AA
? icons: "serch" is not a valid Lucide icon name, omitted from the barrel
icons: 1 unknown name(s) (warnings; use --strict to fail the build)
icons: 16 included (14 core, 2 configured)Generate the utilities sheet
The opt-in utilities sheet is engine output too. Set utilities: true in the config and hyzer generate writes hyzer-utilities.css next to
the tokens sheet. The object form, utilities: { output: '...' }, picks a
custom path. Leave the key out (the default) and you get no utilities file.
--utilities on the command line does the same. It turns the sheet on even when the
config does not. A path you set in the config is still used.
import { defineConfig } from '@hyzer-labs/ui/config';
export default defineConfig({
// true opts in with the default filename; { output } picks a custom path
utilities: true
});$ hyzer generate
config: hyzer.config.ts
wrote hyzer-tokens.css (full, 84 tokens)
wrote hyzer-utilities.css
contrast: 92 pairings checked, all pass WCAG AAScope the sheet to a class
By default the generated sheet roots at :root, and the whole page picks it up.
Sometimes one region needs its own palette and still has to follow the page between light and
dark. A themes entry cannot do that, because one data-theme attribute holds one name at a time. Root the sheet at a class instead, and put that class on the
region:
hyzer generate --mode overrides --selector .theme-ocean --out src/styles/ocean.cssThe config key does the same, so a plain hyzer generate already produces the scoped
sheet:
import { defineConfig } from '@hyzer-labs/ui/config';
export default defineConfig({
selector: '.theme-ocean',
output: 'src/styles/ocean.css',
// ...tokens, themes, etc.
});Set both and the flag wins.
Put the class on a wrapper, and data-theme keeps working inside it, on its own element:
<div class="theme-ocean">
<!-- Ocean's palette applies here, light by default. -->
<section data-theme="dark">
<!-- Same palette, dark now: the class and the attribute compose. -->
</section>
</div>A scoped sheet re-declares more than the tokens you touched. The two-layer color model reads
through var(), and a chain like --hz-intent-primary: var(--hz-palette-primary) has already resolved once it reaches :root. Declared only there, it would keep
the base color under your scope no matter what you set. The generator re-declares every token
that depends on one you changed, under the class, so the whole chain repaints there instead.
selector accepts :root, one class (.theme-ocean) or one
id (#app): one simple selector, with no combinators, commas or attribute
selectors. Anything else is a config error that names selector and the accepted forms.
The utilities sheet is never scoped, even on a scoped
run. A utility class reads its value through var(), so it already paints from the
region's tokens when it's used inside one. Scoping the class itself would make it stop working
everywhere else on the page.
Import order does not change. An overrides sheet still imports after tokens.css, scoped or not.
--check reads the sheet's own record of what it was scoped to, so a run that
disagrees names both sides instead of reporting the whole file as changed. Set selector in the config instead of passing the flag. A check run then reads the same key, so the two can never
disagree.
A full-mode scoped sheet has no automatic prefers-color-scheme block, because
that block is only wired at :root. A scoped region follows light and dark through data-theme instead, on the region itself or on <html>.
You can do this more than once. Point --config at a second file with its own selector and output, and you get a second sheet for a second region.
A build script that runs hyzer generate once per config covers as many regions as you
need.
Most projects never need this
Full config reference
Every group hyzer.config.ts accepts, in one file and commented out. Uncomment
what you need and delete the rest. Each line's comment names the tokens it drives. The values
shown are the current defaults, so uncommenting a line changes nothing until you edit it. npx hyzer init writes this file into your project to start from. On SvelteKit,
the npx sv add @hyzer-labs add-on offers it during setup.
import { defineConfig } from '@hyzer-labs/ui/config';
export default defineConfig({
/**
* Where the sheet goes, and what the default theme is called.
*/
// output: 'src/styles/tokens.css', // path relative to this file; leave it out and the sheet lands here as hyzer-tokens.css
// selector: '.theme-ocean', // root the sheet at a class instead of :root, so one region keeps its own palette
// defaultThemeName: 'brand', // names the tokens block below, and the [data-theme] value that restores it; default 'default'
/**
* The default theme: every token below, with the value the library ships.
* Uncomment a line and change it to make it yours. Leave the rest alone.
*/
// tokens: {
// palette: { // raw hues (--hz-palette-*); single values or ramps
// primary: '#2563eb',
// secondary: '#7c3aed',
// success: '#15803d',
// warning: '#b45309',
// danger: '#b91c1c',
// info: '#0e7490',
// black: '#000000',
// white: '#ffffff',
// gray: '#6b7280'
// },
// color: { // role colors (--hz-color-*)
// surface: 'var(--hz-palette-white)',
// surfaceMuted: 'color-mix(in srgb, var(--hz-palette-gray) 6%, var(--hz-color-surface))',
// text: 'var(--hz-palette-black)',
// textMuted: 'var(--hz-palette-gray)',
// border: 'var(--hz-palette-gray)',
// black: 'var(--hz-palette-black)',
// white: 'var(--hz-palette-white)'
// },
// intent: { // remap or add intents (--hz-intent-*)
// neutral: 'var(--hz-palette-gray)',
// primary: 'var(--hz-palette-primary)',
// secondary: 'var(--hz-palette-secondary)',
// danger: 'var(--hz-palette-danger)',
// warning: 'var(--hz-palette-warning)',
// success: 'var(--hz-palette-success)',
// info: 'var(--hz-palette-info)'
// },
// typography: { // the type scale; each group below names the tokens it drives
// fontSize: { // --hz-font-size-*
// sm: '0.875rem',
// base: '1rem',
// lg: '1.4rem',
// xl: '1.65rem',
// '2xl': '2.75rem',
// '3xl': '3.5rem'
// },
// fontFamily: { // --hz-font-family-*
// sans: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
// serif: "ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif",
// mono: "ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace"
// },
// fontWeight: { // --hz-font-weight-*
// normal: '400',
// medium: '500',
// semibold: '600',
// bold: '700'
// },
// lineHeight: { // --hz-line-height-*
// tight: '1.2',
// base: '1.5',
// loose: '1.75'
// }
// },
// space: { // the fixed margin/gap scale (--hz-space-*)
// none: '0',
// xs: '0.5rem',
// sm: '1rem',
// md: '2rem',
// lg: '4rem',
// xl: '8rem'
// },
// density: { // the --hz-density grid unit, plus a fallback for each ladder rung
// unit: '0.4rem',
// ladder: { // --hz-density-ladder-depth-1..4; these are fallbacks, so a rung you declare in CSS wins
// depth1: 'calc(var(--hz-density) * 10)',
// depth2: 'calc(var(--hz-density) * 5)',
// depth3: 'calc(var(--hz-density) * 2)',
// depth4: 'calc(var(--hz-density) * 1)'
// }
// },
// width: { // layout max-widths (--hz-width-*)
// sm: '640px',
// md: '968px',
// lg: '1200px',
// xl: '1440px',
// full: '100%'
// },
// radius: { // corner radii (--hz-radius-*)
// none: '0',
// sm: '0.25rem',
// md: '0.5rem',
// lg: '1rem',
// full: '9999px'
// },
// border: { // border widths (--hz-border-width-*)
// width: {
// thin: '1px',
// thick: '2px',
// heavy: '6px'
// }
// },
// shadow: { // elevation (--hz-shadow-*)
// sm: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
// md: '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
// lg: '0 20px 25px -5px rgb(0 0 0 / 0.18), 0 8px 10px -6px rgb(0 0 0 / 0.18)'
// },
// zIndex: { // stacking order (--hz-z-*)
// base: '0',
// raised: '1',
// dropdown: '10',
// sticky: '100',
// tooltip: '150',
// popover: '200',
// overlay: '1000',
// modal: '1100'
// },
// motion: { // duration and easing (--hz-duration-*, --hz-ease-*)
// duration: { // --hz-duration-*
// fast: '250ms',
// base: '400ms',
// slow: '550ms'
// },
// ease: { // --hz-ease-*
// standard: 'cubic-bezier(0.2, 0, 0, 1)',
// in: 'cubic-bezier(0.4, 0, 1, 1)',
// out: 'cubic-bezier(0, 0, 0.2, 1)'
// }
// },
// // components: { buttonAccent: 'var(--hz-intent-secondary)', badgeTint: '20%' },
// // Per-component hooks, camelCased, with no --hz- prefix. They have
// // no defaults, so none are listed above. Full list:
// // https://design.hyzer.sh/docs/theming/components
// },
/**
* Named overrides of the default above, one block per data-theme="<name>".
* Each takes any group `tokens` takes, not only color.
*
* `dark` is always emitted, so an entry here changes dark rather than
* creating it. Its name is fixed, because the platform defines it. Any
* other name is yours:
* ocean: { palette: { primary: '#0ea5e9' } }
* print: { typography: { fontSize: { base: '0.9rem' } }, radius: { md: '0' } }
*/
// themes: {
// dark: { // [data-theme="dark"]
// palette: { // --hz-palette-*
// primary: '#60a5fa',
// secondary: '#a78bfa',
// danger: '#f87171',
// warning: '#fbbf24',
// success: '#4ade80',
// info: '#22d3ee',
// gray: '#9ca3af'
// },
// color: { // --hz-color-*
// surface: 'var(--hz-palette-black)',
// surfaceMuted: 'color-mix(in srgb, var(--hz-palette-gray) 25%, var(--hz-color-surface))',
// text: 'var(--hz-palette-white)'
// }
// },
// },
// icons: ['plus', 'trash-2', 'settings'], // trims the generated icons.ts barrel
// utilities: true, // opt in to hyzer-utilities.css (or { output: 'styles/hyzer-utilities.css' })
// contrast: { level: 'AAA' }, // the WCAG bar the report grades against; default 'AA'
// strict: true // fail the run on a contrast miss, an unknown icon or an out-of-date file; --strict does the same
});