@luwiostack/theme
v0.2.1
Published
Headless theming for React — a JSON token config compiled to scoped CSS variables, with a `<Theme>` component, `scope()` markers, and `tokenRef` / `readToken`. Nearest-step and OKLCH-generated colour scales.
Readme
@luwiostack/theme
Headless theming for LuwioStack. You hand it a JSON token config; it emits CSS custom
properties — the global palette on :root, an override block for every scope you declare, and
(optionally) a dark and/or high-contrast palette for colour, plus <link>-loaded external
stylesheets. The library owns the cascade; you own every pixel. This package ships no styles at
all.
This package is React-only — there is no non-React consumer for a theming package — so every runtime export lives at one entry point:
@luwiostack/theme/react—<Theme>, the compiler (compileTheme), the renderers (themeToCss,resolveVars/resolveToken),scope,tokenRef,readToken,mergeThemeConfig,fillScale/generateColorScale,parseColor/contrastRatio,generateContrastVariant/BLACK/WHITE,flattenTokens,setScheme/setContrast, and the two hooksuseThemeModes/useActiveTheme.@luwiostack/theme— the bare root, re-exporting only the config/compiled-theme types (ThemeConfig,CompiledTheme, …), for convenience.
Install
npm install @luwiostack/themereact is a peer dependency.
The full config, at a glance
Every theme config has exactly two top-level keys, and the split between them is the one thing to understand before anything else:
{
"variables": {
"light": {
"color": {
"primary": { "500": "#EB7322", "700": "#da6110" },
"surface": { "500": "#F6F2E9" }
},
"token": {
"radius": { "sm": "5px" },
"font": { "primary": "Inter, sans-serif" }
},
"scope": {
"footer": {
"color": { "primary": { "500": "#14cccc" } },
"token": { "radius": { "sm": "7px" } },
"scope": {
"button": { "color": { "primary": { "500": "#00B7B7" } } }
}
}
}
},
"dark": {
"color": { "surface": { "500": "#1E2122" } }
},
"contrast": {
"light": { "color": { "primary": { "500": "#8A3200" } } }
}
},
"stylesheets": ["https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap"]
}<Theme config={config}>variablesis everything that can vary bylight/darkmode and be overridden by ascope— it holdscolor(primary.500→--color-primary-500) andtoken(radius.sm→--token-radius-sm), the two kinds of value that live inside a mode node and share the samescopeoverride machinery. (Namedvariables, notconfig, so it doesn't collide with theconfigprop on<Theme>itself — that's the whole config object, of which this is one section.)contrast.light(and itscontrast.darkcounterpart, not shown above) is an optional high-contrast variant oflight/dark, not a third peer mode — see High contrast below.stylesheets—variables' one peer — is everything that's fixed for the whole page: never scoped, never mode-dependent, and no other key can join it at the top level. Interpreted, but inert (the list becomes neither a var nor CSS text — see External stylesheets below).
Every group-shaped value — colour or not, whether or not anything should ever override it — lives
under variables.light.token (and its dark / scope counterparts); there's no separate,
fixed-only place for one. font.primary above stays --token-font-primary whether or not you
ever add a dark or scope entry for it — a value that should never move just doesn't get one.
The rest of this document works outward from that split.
How it works
CSS custom properties inherit down the DOM tree. So nesting needs no JavaScript: declare the
palette at :root, re-declare a few variables on an element, and everything inside inherits the
override. A config nested by scope compiles to descendant selectors:
:root { --color-primary-500: #EB7322 }
[data-scope~="footer"] [data-scope~="button"] { --color-primary-500: #00B7B7 }
[data-scope~="app"] [data-scope~="button"] { --color-primary-500: #00C066 }A button in the footer and a button in the app read the same token name and get different colours, decided purely by where they sit — the cascade is the resolution algorithm, so a component never has to ask what theme it's in.
That is why theming itself needs no context:
<Theme config={config}> // compiles the config, emits one <style>
<button {...scope('button')} style={{ background: tokenRef('color.primary.500') }} />scope() and tokenRef() are both plain functions that read nothing back from <Theme>.
<Theme> does provide one small internal context, but purely so useThemeModes() can read the
theme it already compiled by calling it with no arguments — see Which modes are available
below; nothing about how a variable actually reaches an element goes through it.
variables
variables holds a light / dark peer pair — no fill here; a missing step always fills by
copying the nearest one you defined (see Complete scales below), and there's no config knob to
change that for just one branch. light and dark are themselves self-similar: each holds
color (colour groups — primary, surface, …), token (every other scopable group — radius,
spacing, …), and an optional scope map of named overrides with exactly the same shape, nested
as deep as you like.
color vs token
Every group-shaped value in this package is either a color or a token — the question
that decides which is just "is this a colour?":
| | Lives at | Var name |
|---|---|---|
| color | variables.light.color / variables.dark.color, and inside any scope | --color-<group>-<step> |
| token | variables.light.token / variables.dark.token, and inside any scope | --token-<group>-<step> |
color and token compile identically in every other way — same scoping, same independent
light/dark compilation — but only color scale-fills: a token group, whatever it's called
(radius, spacing, …), always passes through exactly as written, no interpolation, no
nearest-copy — write every step you need. token is where every non-colour group lives —
radius, spacing, font.primary, a border width — whether or not a scope or a colour scheme
should ever actually retint it. A value that must never vary is just a token group nobody
restates in dark or a scope: nothing stops a later addition from starting to vary it, so
that's a discipline you keep, not a guarantee the compiler enforces for you.
Group names are yours — the compiler doesn't know any of them
primary, surface, radius, spacing — none of these are keywords. The compiler has no
built-in list of colour or token names; it just walks whatever keys you put under color /
token and literal-joins the path into a var name. Name a colour group pineapple and you get
--color-pineapple-500, exactly as validly as primary gets --color-primary-500:
// None of these are keywords — the compiler just walks whatever keys are there.
"color": { "pineapple": { "500": "#EB7322" } } → --color-pineapple-500
"token": { "elevation": { "sm": "0 1px 2px #0002" } } → --token-elevation-smThe only names that are reserved are the structural ones — color, token, scope, light,
dark, contrast (itself holding only light / dark), variables, stylesheets — never a
group name inside them; no other key can join variables and stylesheets at the top level.
That said, a consistent palette makes a theme easier to reuse across components and to swap later.
For color, a reasonable default set to reach for:
primary the brand's main colour
supporting a secondary brand colour, used sparingly
accent a colour that draws the eye — a highlight, a call-to-action
surface backgrounds — cards, panels, the page itself
border dividers and outlines
text foreground/ink colours
success a positive state — confirmations, "done"
error a negative state — failures, destructive actions
warning a cautionary state
info a neutral, informational stateNone of these are required, and nothing stops you from adding more (danger, muted, a
brand-specific name) — they're a starting vocabulary, not a schema. The same freedom applies to
token: name a group whatever the design system calls it (radius, spacing, elevation,
borderWidth, …) and it resolves to --token-<yourName>-<step> with no configuration.
Defaults
Every <Theme> should be handed a defaults — the org or app's brand baseline, what the UI looks
like completely unthemed. config is the tenant's (or user's) layer on top of it, and in the
common case starts out empty or close to it, growing only as someone actually customizes
something:
const BRAND_DEFAULTS = {
variables: {
light: { color: { primary: { 500: '#5278f5' }, surface: { 500: '#ffffff' } } },
dark: { color: { surface: { 500: '#1e2122' } } },
},
}
<Theme config={tenantConfig} defaults={BRAND_DEFAULTS}>BRAND_DEFAULTS here stands in for whatever your app's own complete brand palette is —
primary/surface/neutral/success/warning/error, both light and dark filled in — that
an app can hand to every <Theme> it mounts and then never touch. A brand-new tenant's config
can be {} and still render the full brand look; a tenant that wants its own primary writes
just that one group and inherits everything else from BRAND_DEFAULTS, dark palette included,
unchanged.
compileTheme(config, { defaults }) (and <Theme config defaults>) deep-merges defaults
underneath config before compiling — config wins wherever it defines something, and
defaults fills in everywhere it doesn't, right down to a single missing colour step:
const BASE_THEME = {
variables: { light: { color: { primary: { 500: '#5278f5' }, surface: { 500: '#ffffff' } } } },
}
<Theme config={{}} defaults={BASE_THEME}> {/* → the full base theme */}
<Theme config={{ variables: { light: { color: { accent: {...} } } } }} defaults={BASE_THEME}>
{/* → primary and surface from BASE_THEME, plus accent from config */}The merge is generic and recursive — a scope map on both sides merges scope-by-scope, a group
both sides touch merges step-by-step — so it's the same deep-merge whether you're filling in a
whole missing palette, a single missing colour, or a token group. A scale that's only complete
once config and defaults are merged still nearest-fills correctly from the combined anchors.
Pass a stable defaults — <Theme> compares it by identity, same as config, to decide whether
to recompile.
An empty config vs. an empty variables
config with no variables key at all ({}) is the full inherit — the merged theme's
variables is defaults.variables, unchanged, dark / contrast.light / contrast.dark
included exactly as defaults declared them. That's the recommended way to onboard a new tenant:
hand it {} and let defaults carry everything.
The moment config.variables is present — even as {} — dark, contrast.light, and
contrast.dark stop being automatic. light still always fills in from defaults.variables.light
when config.variables.light is absent (that part is unchanged); but for the other three, only a
key config.variables (or, for the contrast pair, config.variables.contrast) itself owns is
merged with defaults' version of it — a key it doesn't own is left out of the merged theme
entirely, even when defaults has one:
// config has no `variables` key → full inherit, dark included
<Theme config={{}} defaults={BRAND_DEFAULTS}>
// config.variables is present, but has no `dark` key → NO dark mode at all,
// even though defaults has one
<Theme
config={{ variables: { light: { color: { primary: { 500: '#EB7322' } } } } }}
defaults={BRAND_DEFAULTS}
>
// add `dark: {}` (even empty) to opt back in — pulls in the whole of
// defaults.variables.dark
<Theme
config={{
variables: {
light: { color: { primary: { 500: '#EB7322' } } },
dark: {},
},
}}
defaults={BRAND_DEFAULTS}
>contrast.light/contrast.dark gate independently, one level deeper, the exact same way: once
config.variables.contrast exists at all, each of light/dark inside it needs its own key
(even {}) to inherit anything — an empty contrast: {} alone opts into neither variant, since
it names neither. See Dark mode and High contrast below for what this means for each of
those sections specifically.
A partial override can look incoherent
The merge fills in per group, not per palette: override primary alone and every other colour
group (accent, supporting, surface, …) still comes from defaults, untouched — chosen to sit
next to defaults' own primary, not necessarily yours. Group names aren't keywords the compiler
understands (see Group names are yours above), so it has no notion that accent is "related
to" primary and can't warn you when overriding one drifts the two apart. If you override one
colour in a brand palette, override (or at least eyeball) the rest of it too, rather than
cherry-picking a single group and trusting the others to still read well next to it. Getting a
whole palette to cohere from a single brand colour is a design decision this package deliberately
leaves to you — or to a separate tool upstream of it — not something compileTheme derives on its
own.
Dark mode
light and dark compile fully independently — neither borrows from the other, so if you
want a colour to exist in both modes, you write it in both. Leaving a colour or a radius step out
of dark doesn't mean "the same as light": it means dark never sets it, so under
data-scheme="dark" that var just keeps whatever :root already declared, via ordinary CSS
inheritance — fine when light does declare it (that's a legitimate "doesn't change by mode"
choice), but if a group only ever appears under dark, it's undefined everywhere else.
compileTheme warns in dev when that happens.
{
"variables": {
"light": { "color": { "primary": { "500": "#5278f5" }, "surface": { "500": "#ffffff" } } },
"dark": { "color": { "primary": { "500": "#5278f5" }, "surface": { "500": "#1e2122" } } }
}
}primary is restated for dark here, byte-for-byte identical to light's own value — that's what it
now takes to keep a colour the same across modes. surface genuinely differs, so both produce real
dark-mode CSS:
:root { color-scheme: light dark; --color-primary-500: #5278f5; --color-surface-500: #ffffff }
@media (prefers-color-scheme: dark) {
:root:not([data-scheme="light"]) { --color-surface-500: #1e2122 }
}
:root[data-scheme="dark"] { --color-surface-500: #1e2122 }(--color-primary-500 produces no dark-mode CSS at all, even though dark restates it — the
diff against light's own compiled value comes out empty, since they're identical. Repeating a
value costs you nothing in the compiled output; it only costs you writing it out.)
:root also carries a native color-scheme declaration — light dark once the theme has a real
dark half, light when it doesn't — so the browser's own UI (native form controls, scrollbars,
the default canvas background) follows data-scheme too, not just your custom properties. It's
derived automatically from whether dark ends up defined; there's no config key for it.
The @media block follows the OS preference, guarded by :not([data-scheme="light"]) so an
explicit light choice suppresses it. The plain :root[data-scheme="dark"] block is for an
explicit choice, regardless of what the OS says — both anchored to :root, since the scheme
is one app-wide switch, not a per-subtree thing. That's three states from one attribute:
document.documentElement.dataset.scheme = 'dark' // force dark
document.documentElement.dataset.scheme = 'light' // force light
delete document.documentElement.dataset.scheme // follow the OSsetScheme (from @luwiostack/theme/react) is a thin wrapper around exactly that DOM write, for
when a plain function reads better than three lines of dataset juggling:
import { setScheme } from '@luwiostack/theme/react'
setScheme('dark') // force dark
setScheme('light') // force light
setScheme(null) // follow the OSThere's no component for this — setScheme (or the raw DOM write) is all toggling ever is (see
Which mode is actually active below for a hook that both reads the resolved state and
returns this same setter). Persist the user's explicit choice however you already persist
preferences (@luwiostack/storage, localStorage, …) and apply it before paint to avoid a flash.
Each side of a scale fills independently from its own anchors — so a colour defined only in dark
gets its own nearest-filled scale from its own steps alone, never borrowing extra anchors from
light's version of the same group. A scope's light/dark pair works exactly the same way, at
that scope's own depth: nothing above or beside it fills in what it doesn't restate.
Opting out of dark via defaults
The independent-compilation rule above governs light vs dark within one config. When a
defaults is also involved (see Defaults above), there's a second, separate question: does
this app's dark even exist at all, once config and defaults are merged?
If config.variables is present, dark is opt-in by key presence: config.variables must
own a dark key — even {} — for the merged theme to have a dark section at all, regardless of
what defaults declares. Leave the key out entirely and the app has no dark mode, full stop, even
though defaults has a real one:
const defaults = { variables: { light: {...}, dark: { color: { surface: {...} } } } }
// no `dark` key in config.variables → merged theme has NO dark section —
// this app never renders dark-mode CSS, even though defaults has one
<Theme config={{ variables: { light: { color: { primary: {...} } } } }} defaults={defaults}>
// `dark: {}` is enough to opt back in — pulls in the whole of defaults' dark,
// then deep-merges anything config's own `dark` adds on top, same as any other merge
<Theme
config={{ variables: { light: { color: { primary: {...} } }, dark: {} } }}
defaults={defaults}
>This is a deliberate per-app opt-out, not a bug: a brand's defaults can ship a full dark palette
while a particular deployment simply doesn't want dark mode. Only relevant when defaults is in
play — with no defaults, config.variables.dark either exists or it doesn't, same as always. (If
config has no variables key at all, none of this applies — see Defaults above.)
High contrast
variables.contrast.light / variables.contrast.dark are optional high-contrast variants of
light / dark — not a third scheme. They compile entirely from their own content, with no
fallback to light / dark (or to each other) for anything they don't restate — the same
independent-compilation rule light/dark themselves follow, one level up. A variant that
restates only some steps of a scale doesn't inherit the rest from its base any more: its own
nearest-fill has nothing else to draw on, so every step floods to whichever anchor it did
restate. In practice that means a real contrast variant almost always needs every step spelled
out — see Generating high contrast below for the tool that produces that.
{
"variables": {
"light": {
"color": { "primary": { "500": "#EB7322" }, "surface": { "500": "#FFFFFF" } }
},
"contrast": {
"light": {
"color": { "primary": { "500": "#8A3200" } }
}
}
}
}(surface isn't restated here, so contrast.light simply never sets it — it falls back through
the ordinary cascade to light's own --color-surface-500, same as leaving a colour out of dark
does.)
Toggling is a second, orthogonal attribute — independent of data-scheme, so all four combinations
of scheme × contrast work without either axis knowing about the other:
document.documentElement.dataset.contrast = 'high' // force high contrast
document.documentElement.dataset.contrast = 'normal' // force normal contrast
delete document.documentElement.dataset.contrast // follow prefers-contrast: moreSame shortcut as setScheme: setContrast('high'), setContrast('normal'), setContrast(null).
Opting out of a contrast variant via defaults
Same rule as Dark mode's opt-out (see above), one axis over: when config.variables is
present, contrast.light and contrast.dark are opt-in by key presence too — independently of
each other and of dark. defaults can ship both; a particular config gets neither, either, or
both, independently, just by owning (or not owning) the corresponding key inside
config.variables.contrast:
const defaults = {
variables: {
light: {...}, dark: {...},
contrast: {
light: { color: { primary: {...} } },
dark: { color: { primary: {...} } },
},
},
}
// config.variables.contrast has no `light` / `dark` key → no contrast variant
// at all, in either scheme, even though defaults ships both
<Theme config={{ variables: { light: {...} } }} defaults={defaults}>
// `contrast: { light: {} }` opts back into defaults' contrast.light only —
// contrast.dark is still absent
<Theme config={{ variables: { light: {...}, contrast: { light: {} } } }} defaults={defaults}>As always, a config with no variables key at all skips this rule entirely and inherits
defaults.variables whole, contrast variants included — see Defaults above. And an empty
contrast: {} (present, but naming neither light nor dark) opts into neither variant — name
at least one to inherit anything.
Generating high contrast instead of hand-authoring it
Writing every boosted colour by hand works, but you don't have to start from nothing:
generateContrastVariant is a tool, not a compileTheme mechanism — call it once, offline,
against your own palette, and paste the result in as literal contrast.light / contrast.dark
steps:
import { BLACK, generateContrastVariant, WHITE } from '@luwiostack/theme/react'
generateContrastVariant(config.variables.light, WHITE) // → a full contrast.light node
generateContrastVariant(config.variables.dark, BLACK) // → a full contrast.dark nodeIt pushes every colour's OKLCH lightness (hue and chroma held fixed) toward whichever pole
increases contrast against the reference — WHITE for a light-mode reference, BLACK for dark —
until it reaches targetRatio (a third argument, default 7, WCAG AAA). A colour that already
meets the target passes through unchanged. It only ever touches color groups; a token group
(radius, …) comes back exactly as base wrote it, since contrast is a colour concern.
Hand-edit whichever colours you'd rather pick yourself in the pasted result before committing it —
compileTheme never re-runs this for you, so nothing you write here goes stale or gets silently
overwritten later.
A background (surface, …) usually shouldn't go through this at all: pushed toward whichever pole
increases contrast against itself, a colour that already matches the reference has nowhere to go
but the opposite one — a light theme's white background turning grey. Paste its own base values
back in afterwards (or skip it in the first place) rather than the generated ones.
Which modes are available
Toggling scheme and contrast (see above) is always a plain DOM write — but deciding whether to
render the toggle button at all needs to know what the theme actually supports first. Both
themeModes (a plain function) and useThemeModes (its React hook) answer that from a
compiled theme, not the raw config, so a contrast.light: {} that ends up compiling to no
visible difference correctly reports hasContrast: false. Call the hook two ways:
import { useThemeModes } from '@luwiostack/theme/react'
// Under <Theme config={config} defaults={defaults}> — reads the already-compiled theme straight
// from <Theme>'s own context, no config to pass, can never disagree with what it rendered:
const { modes, hasContrast } = useThemeModes()
// Anywhere else — a toggle rendered as a sibling of <Theme> rather than a descendant, or before
// <Theme> exists at all — name the same config/defaults explicitly instead:
const { modes, hasContrast } = useThemeModes(config, defaults)
// modes → [{ scheme: 'light', hasContrast: true }, { scheme: 'dark', hasContrast: false }]
// — or just [{ scheme: 'light', hasContrast: true }] for a theme with no dark section at all.
{modes.map(({ scheme }) => <SchemeButton key={scheme} scheme={scheme} />)}
{hasContrast(activeScheme) && <ContrastButton />}light is always listed — it's the mandatory base, every theme has one. dark is listed only
when the compiled theme actually has a dark section: omitted entirely when config (merged with
defaults, if any) never defines one — including the opt-out case above, where
config.variables is present but owns no dark key. Render your scheme toggle off modes
itself, rather than assuming two entries, and it never offers a switch to a mode that doesn't
exist. themeModes(compiled) is the plain function underneath, for non-React or SSR use.
Calling the hook with no arguments requires an ancestor <Theme> — it throws otherwise, since
there's nothing to read. This is the one place in the package that reads context at all; nothing
about styling itself does (see How it works above) — the context exists solely so this hook
doesn't have to recompile config/defaults a second time, or ask you to import them again just
to hand them to it. Reach for the explicit (config, defaults?) form wherever there's no ambient
<Theme> to read from.
Which mode is actually active
useThemeModes answers what's available; useActiveTheme answers what's in effect right
now — for a toggle button that shows the live state, or any component that needs to branch on
the resolved scheme/contrast rather than just re-reading the CSS variables. It reads exactly what
the compiled CSS itself reads: an explicit document.documentElement.dataset.scheme /
.dataset.contrast when one is set, falling back to prefers-color-scheme /
prefers-contrast: more when it isn't — so it can never disagree with what's actually painted:
import { useActiveTheme } from '@luwiostack/theme/react'
const { scheme, contrast, setScheme, setContrast } = useActiveTheme()
// { scheme: 'dark', contrast: 'normal', setScheme, setContrast } — live, not a snapshotNo config, no props — this reads global document / OS state, not a theme. It stays live: a
MutationObserver catches the next dataset.scheme = 'dark' write (yours, setScheme's, or
anyone else's), and a matchMedia listener catches the OS preference changing underneath you, with
no polling. On the server (or before hydration) it reports
{ scheme: 'light', contrast: 'normal' } — the same default the compiled CSS itself falls back to
absent either attribute — then re-resolves on mount.
setScheme / setContrast are the same standalone functions from Dark mode / High
contrast above, included here purely so a toggle button can destructure the live value and its
setter from one call:
const { scheme, setScheme } = useActiveTheme()
<button onClick={() => setScheme(scheme === 'dark' ? 'light' : 'dark')}>Toggle</button>Marking scopes
scope(name) returns the one attribute the compiled selectors match on:
scope('button') // { 'data-scope': 'button' }Spread it onto your own element. It's a plain function — no provider, no hook, works outside React.
import { Theme, scope, tokenRef } from '@luwiostack/theme/react'
import config from './theme.json'
function App() {
return (
<Theme config={config}>
<main {...scope('app')}><Page /></main>
<footer {...scope('footer')}><Legal /></footer>
</Theme>
)
}<Theme> renders one <style> holding the whole stylesheet. It's inline rather than injected
into <head>, so it's present in the first byte on the server — no flash of unstyled content.
A component name is a scope, so a component can mark itself and then theme differently depending on where it's mounted:
function Button({ children, ...rest }) {
return <button {...rest} {...scope('button')}>{children}</button>
}Composing with your own props
scope() returns a single data-scope attribute, so it merges like any other prop — but order
decides the winner, exactly as with className or style:
// ✅ marker last: the caller's className / onClick / aria-* / data-* all survive,
// and a stray data-scope in `rest` can't silently replace yours.
<button {...rest} {...scope('button')} />
// ⚠️ marker first: a caller passing data-scope would override it.
<button {...scope('button')} {...rest} />Other data-* attributes are untouched — only data-scope is involved:
<button {...scope('button')} data-testid="buy" data-state="loading" className="cta" />One element, one scope. A scope is the component (or region) it marks, so nesting is
expressed by nesting: footer → button compiles to a descendant selector, and the footer marks
its element while the button marks its own.
Styling then needs no hooks at all:
.button { background: var(--color-primary-500) }…or tokenRef('color.primary.500') if you'd rather not hand-write the name.
Complete scales
A scale is a token group's complete, ordered key set — the colour steps 50…950. Scales are
always emitted complete, so every token reference resolves even when you defined only a few
steps. If a color group defines at least one step, its whole scale is filled. color is the
only scale built in — every token group, whatever it's called (radius, spacing, …),
passes through exactly as written, with no interpolation and no fill: write every step you need
yourself.
compileTheme fills a missing color step by copying the closest one you defined — the one
thing it ever does, and the only group it ever does it to, with no config knob to change either:
with 400/500/600 set, 600 fills 700 and 400 fills 300; ties resolve to the lower step.
Filling itself is per node from its own keys — so overriding one primary step in a scope
re-fills that scope's whole primary scale from its own anchors alone, while colour groups it
doesn't touch keep inheriting. A token group works differently, since it's never filled:
restating one radius step in a scope sets only that step — the rest simply aren't set by that
scope, and fall through the cascade to whatever an ancestor (or :root) already declared.
Generating a scale instead of hand-authoring it
Nearest-copy is a coarse look for a big scale — banded, not smooth. For something closer to a
hand-tuned ramp, generateColorScale is a tool, not a compileTheme mechanism: it
interpolates your anchors in OKLCH (Björn Ottosson's perceptual colour space, so even steps look
even) and extrapolates past the ends along the end pair's slope. Call it once, offline, and paste
the complete result into your config as literal steps:
import { generateColorScale } from '@luwiostack/theme/react'
generateColorScale({ '500': '#EB7322', '700': '#da6110' })
// → { '50': '#...', '100': '#...', …, '500': '#EB7322', …, '700': '#da6110', …, '950': '#...' }To make the generated tints smaller or bigger steps, pass spread (default 1; must be finite and
> 0):
generateColorScale({ '500': '#EB7322' }, { spread: 0.6 }) // flatter: 50 and 950 stay close to 500
generateColorScale({ '500': '#EB7322' }, { spread: 1.5 }) // punchier: closer to white / blackWith a single anchor it scales how far 50 and 950 travel toward white and black; with several
anchors it scales only the tints that run past the outermost anchors. Tints between your anchors,
and the anchors themselves, never move.
With 500 and 700 set, 600 lands between them, 800 continues darker, 400 continues
lighter. It's shaped for a color scale specifically — a thin wrapper around the lower-level
fillScale, also exported, for a custom scale of your own (fillScale takes any Scale, not
just the built-in 50–950 one generateColorScale uses internally). A value that isn't a colour
quietly falls back to nearest-copy instead of erroring — fillScale's own fallback, not something
compileTheme ever triggers for you: compileTheme doesn't scale-fill token groups at all,
radius included, whatever it contains.
External stylesheets
stylesheets — variables' one peer at the top level — is a list of external stylesheet URLs
that already define their own CSS — most commonly @font-face rules, the way Google Fonts' css2
endpoint works, but the mechanism isn't font-specific. It's fixed for the whole page: no
light/dark split, no scope override, and no other key can join it at the top level.
{
"stylesheets": [
"https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap"
]
}Nothing about a URL's content is compiled or validated — compileTheme just carries the list
through to CompiledTheme.stylesheets. <Theme> renders each one as a <link rel="stylesheet">
alongside its <style>, preceded by a <link rel="preconnect"> to its origin:
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap">This is deliberately not an @import inside the generated CSS text — @import blocks the
browser from discovering and fetching the stylesheet until the rest of the CSS has parsed, and
can't be preconnected. A <link>, rendered up front, lets the browser start fetching in parallel.
A URL whose origin is detected as https://fonts.googleapis.com gets a second preconnect for
free, matching Google's own documented pair exactly:
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap">That second origin — fonts.gstatic.com — is where the actual font files a Google Fonts
stylesheet references are hosted; this package has no generic way to discover it for any other
URL, since it never fetches or parses the stylesheet itself. Two resources sharing an origin
de-duplicate to one preconnect.
config.stylesheets fully replaces defaults' (arrays merge like any other non-object leaf
in this package — see Defaults above).
For a fluid, viewport-relative type/space scale (clamp() vars ported from utopia.fyi's own
calculator) — an unrelated concern with nothing to do with stylesheets — see the standalone
@luwiostack/utopia package instead. It has no dependency on this one, and vice
versa; mount its <Utopia config> as a sibling of <Theme>:
import { Theme } from '@luwiostack/theme/react'
import { Utopia } from '@luwiostack/utopia/react'
<Theme config={themeConfig}>
<Utopia config="on" />
{children}
</Theme>Content Security Policy
The compiled <style> accepts a nonce, for a style-src policy that doesn't include
'unsafe-inline':
<Theme config={config} nonce={nonce} />Reading a token as a value
For a canvas, a chart library, an export — anywhere CSS can't reach — read it off a mounted element:
import { readToken } from '@luwiostack/theme/react'
function Chart() {
const ref = useRef<HTMLDivElement>(null)
const [stroke, setStroke] = useState<string>()
useLayoutEffect(() => {
if (ref.current) setStroke(readToken(ref.current, 'color.primary.500'))
}, [])
return <div ref={ref}><Canvas stroke={stroke} /></div>
}readToken asks the browser, so it is the cascade rather than a model of it: whatever scope
the element sits in — and any CSS you wrote by hand — is already accounted for. That's why the
package ships no hook for this: there's nothing to keep in sync, and a ref plus an effect is all
it takes.
Outside the browser (SSR, a build script, a test with no DOM) use resolveToken instead, passing
the scope path explicitly — and { dark: true } to resolve as dark mode would.
Using the compiler directly
compileTheme / themeToCss / resolveToken have no React in them — they're plain functions,
just exported alongside <Theme> from @luwiostack/theme/react (this package's one entry point —
see above) rather than from the bare root. Reach for them directly for SSR, a build script, or
anywhere else you'd rather drive the compiler yourself instead of mounting <Theme>:
import { compileTheme, themeToCss, resolveToken } from '@luwiostack/theme/react'
const compiled = compileTheme(config) // fills scales, diffs light/dark, merges rules
const css = themeToCss(compiled) // ":root{…}" + scope rules + the dark blocks
resolveToken(compiled, ['footer', 'button'], 'color.primary.500') // '#00B7B7'
resolveToken(compiled, ['footer', 'button'], 'color.primary.500', { dark: true }) // dark's valueresolveToken applies every scope rule whose chain is an order-preserving subsequence of the
path — the same thing a descendant combinator matches — so a server-rendered answer agrees with
what the browser will compute. resolveVars(compiled, scopePath, opts?) is the lower-level
function underneath it, returning every variable in effect at that path instead of just one.
compileTheme(config, { defaults }) is what <Theme config defaults> calls under the hood —
mergeThemeConfig(defaults, config) is the plain merge function underneath it, exported for when
you want the merged config itself rather than a compiled theme. flattenTokens(node) is a
simpler, unfilled flattener for when you just want to literal-join a token tree's keys into var
names without going through the scale-filling walk compileTheme does.
