npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 hooks useThemeModes / useActiveTheme.
  • @luwiostack/theme — the bare root, re-exporting only the config/compiled-theme types (ThemeConfig, CompiledTheme, …), for convenience.

Install

npm install @luwiostack/theme

react 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}>
  • variables is everything that can vary by light / dark mode and be overridden by a scope — it holds color (primary.500 → --color-primary-500) and token (radius.sm → --token-radius-sm), the two kinds of value that live inside a mode node and share the same scope override machinery. (Named variables, not config, so it doesn't collide with the config prop on <Theme> itself — that's the whole config object, of which this is one section.) contrast.light (and its contrast.dark counterpart, not shown above) is an optional high-contrast variant of light / 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-sm

The 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 state

None 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 OS

setScheme (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 OS

There'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: more

Same 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 node

It 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 snapshot

No 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 / black

With 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 value

resolveToken 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.