Skip to content

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

You do not need either one to theme this library. Plain CSS overrides work on their own. Tokens & Overrides covers that route. Reach for a config when you want your system in one typed file, contrast graded on every build, and a sheet you regenerate rather than maintain.

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.

ts hyzer.config.ts
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 AA

Write 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

Ramps are free to define. The report grades text and intent tokens against your surfaces, not raw palette hues, so extra rungs add no pairings on their own. The cost comes when you point an intent at a ramp's end rung. Those rungs are very pale and very dark by design. Neither clears 4.5:1 as text, so the report fails that pairing. --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:

bash
# 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

They load through Node's native type stripping. On older runtimes, name the file 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.

FlagConfig keyWhat it does
--config <path>—Which config file to read. Defaults to hyzer.config.ts, .js or .mjs in the current directory.
--out <path>outputWhere 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>selectorWhere 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.
--utilitiesutilitiesAlso 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.
--strictstrictExit 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.

ts hyzer.config.ts
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.

ts hyzer.config.ts
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 AA

Scope 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:

bash
hyzer generate --mode overrides --selector .theme-ocean --out src/styles/ocean.css

The config key does the same, so a plain hyzer generate already produces the scoped sheet:

ts hyzer.config.ts
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:

html
<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

A named theme is simpler and covers almost every case. Section themes explains when this is the right tool instead.

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.

ts hyzer.config.ts

Where to go next

Tokens & Overrides

The two-layer token model the generated sheet writes, and how to reach it in plain CSS.

Contrast & Accessibility

The report's math, as live ratios you can read off the page.

Example themes

Complete configs you can copy, from a token-only theme to one built from scratch.