HelixOverviewComponentsMapsGraphIntroductionInstallationDeriving a themeSandboxTheme builderVisual testingCortexDocsEngramBuildingTraceBuildingRecallDeclaredServicesInsightsGet in touchSource on GitHub
Theming
ThemingHelix computes your color tokens from a few inputs, such as your brand color.Without a brand colorWith no brand color set, Helix draws in ink. The primary color is near-black in
light mode and near-white in dark mode, and a filled button is the opposite of
the page, with the page's own color for its label. The neutral ramp supplies
both, so a gray with a tint gives a tinted ink. Set a brand color to replace the
ink, as a seed in CSS or as brand in the theme engine.Before ink, the default was a blue. To keep it, set it as your seed::root{--helix-seed-brand:oklch(0.5390.19258);}When a design system ships two hundred fixed color values, you theme it by
changing them one at a time. A change to one value can break the contrast of a
component elsewhere, and nothing tells you.@fusion.dev/helix-theme computes a complete token set from a few intent
inputs. It solves each text color against the backgrounds it lands on, measures
every pair in its surface model, and reports any pair that misses its contrast
target. foreground-subtle is not in the model. It is a fixed step of the
stylesheet's gray ramp, and the engine does not solve it, so its contrast is not
guaranteed.Two ways to themeHelix has two ways to theme, and they give different guarantees.Tuned uses CSS only. Set a seed and the stylesheet recomputes the colors,
with nothing to install or run::root{--helix-seed-brand:oklch(0.580.1920);--helix-seed-accent-hue:140;--helix-seed-chroma-scale:0.4;}Set seeds at :root or on an element with [data-helix-theme]. A seed on any
other element has no effect. A var() inside a custom property is substituted
on the element where the property is declared, so the ramp has already
resolved at the root. The stylesheet declares the ramp again on each
[data-helix-theme] element, so the ramp there reads the seeds you set on it.Tuned does not guarantee contrast. CSS has no contrast function.
oklch(from …) can step and rotate a color, but it cannot search for the
lightness that meets a target. The contrast figures built into the ramp were
measured on one blue seed, so they are exact for that seed and approximate for
any other.Verified uses the theme engine. It searches for each color that meets its
target, measures every pair, and returns the theme with any violations:import{readFile}from'node:fs/promises'import{initTheme}from'@fusion.dev/helix-theme'constengine=awaitinitTheme(awaitreadFile(newURL(import.meta.resolve('@fusion.dev/helix-theme/wasm/fusion_helix_theme_bg.wasm'))),)consttheme=engine.derive({brand:'#0b3d91',contrastTarget:4.5,// 4.5 is WCAG AA, 7 is AAAscheme:'light',},[':root'],)if(theme.violations.length>0){thrownewError(theme.violations.join('\n'))}console.log(theme.css[':root'])The engine is WebAssembly, so initTheme loads it once and you wait for it.
Node passes the module's bytes, as above. A browser passes the URL you serve
fusion_helix_theme_bg.wasm from, such as initTheme('/theme.wasm').
derive renders the CSS under each selector you name.The CSS includes the seeds as well as the tokens. The stylesheet
computes the values the engine does not, from the same seeds the engine used:The twenty-four series colors for chartsThe spacing scaleThe corner radiiThis keeps both ways of theming in agreement on the same page.Use tuned to try a color. Use verified for the theme you ship.Derive both modesA site with light and dark mode needs both themes. deriveModes derives your
intent in each mode and renders one block, with each color as a
light-dark() pair, the way the shipped stylesheet writes its tokens:import{initTheme}from'@fusion.dev/helix-theme'constengine=awaitinitTheme('/theme.wasm')const{css,dark,light}=engine.deriveModes({brand:'#0b3d91'},[':root'])for(constthemeof[light,dark]){if(theme.violations.length>0){thrownewError(theme.violations.join('\n'))}}console.log(css[':root'])color-scheme picks the half, so the block follows the operating system and
[data-color-mode] on any element, as described in Color modes.
A value that is not a color and still changes with the mode, such as the edge
of a floating surface, is set by rules after the block, one for each mode.The CLI writes the same block. helix theme init writes a helix.theme.json,
with your brand color if you pass --brand. helix theme build derives it in
both modes, unless the file names a scheme, and refuses to write a theme with
a pair that misses its target:helix theme init --brand'#0b3d91'helix theme build --out theme.cssColor one sectionA section in a color of its own, such as a deep teal band on a cream page, is a
theme of its own. Derive it from the same intent, with the section's color as
its page, under a selector for the section:constband=engine.derive({...intent,background:{dark:'#0b2a33'},scheme:'dark'},['[data-band]'],)Choose the scheme the color needs: dark for a color light text reads on, and
light for one dark text reads on. A color no text can reach the contrast
target on is refused, as a page is.Mark the section so the stylesheet derives its own colors there from the
section's seeds, in the section's mode, and paint it with its page color:<sectiondata-banddata-color-mode="dark"data-helix-theme>…</section>[data-band]{background-color:var(--helix-color-background);color:var(--helix-color-foreground);}Write the section's block after the page's, so it wins inside the section.Why Helix uses OKLCHEqual numeric steps in sRGB do not look like equal steps. That makes scales and
contrast math in sRGB unreliable. Helix computes themes in OKLCH for this
reason. Colors are written in OKLCH and converted to other formats on output.The engine keeps your brand color's hue and chroma. Where the color already
meets the contrast target, the engine uses it as it is: a navy brand stays navy
on a light page. Where it does not, the engine changes only its lightness, by as
little as the target needs. Your brand color stays recognizable, and its
contrast is guaranteed.Errors the engine preventsThe engine prevents two errors. Each one produces a palette that passes review
and fails in production. Regression tests cover both.Colors outside the sRGB gamutA near-white surface with a brand tint can fall outside the sRGB gamut.
Converting it to hex then clips it, which changes its hue and breaks the
contrast the engine solved for. The engine fits every color into the gamut
before it solves against that color. When fitting changes a token, the token
reports chromaReduced.Text on more than one surfaceText appears on surface as often as on background, for example in cards,
dialogs, and menus. In a dark theme, surface is lighter than background.
Light text that passes on the page can lose contrast inside every card. The
engine solves each foreground color against the hardest surface it appears on.Inspect a themeEach derived token includes the result of its contrast check:consttheme=engine.derive({brand})for(consttokenoftheme.tokens){console.log(token.name,token.hex,token.contrast,token.chromaReduced)}violations is empty when the theme passes. A color that cannot reach its
target at the requested chroma is reported in violations, so you can fix it
before you ship.