One base theme, refracted into every brand — and you see what changes before you ship.
A framework-agnostic theme compiler built for white-label and multi-tenant work: author one RawTheme, derive a brand child in a line with override(), and refract diff any change to see its blast radius before it ships. The same theme exports to CSS, SCSS, JSON or styled-components — the format is just an adapter. Flip a preset — every panel below re-renders from real, in-browser-compiled output.
Left is authored input; right is a live component built from the classes refract emitted for it — compiled in your browser. Flip a preset in the top bar and both sides re-render from that theme. See how components compose ▸
Why refract
Most theming tools couple your design tokens to one output format and one framework. refract keeps the theme format-neutral — you author it once, and an adapter decides the format. Plain CSS variables give you tokens but no synthesis or composition; utility frameworks and CSS-in-JS token tools are excellent but commit you to their output target and runtime. See how refract compares to Tailwind, Panda, Style Dictionary and others.
Author once; adapters refract the same Model into CSS, SCSS, JSON tokens or styled-components — or write your own for any target (React Native, Compose, a token pipeline) against a stable defineAdapter contract. Not a fixed list — the format is a plugin. Framework-agnostic by construction; the Model holds no CSS syntax.
Recipes reference tokens by path. Override a base value and everything downstream re-flows — tonal palettes re-synthesize, no find-and-replace.
A component is a class list composed from the other subsystems — one class, every layer, and the same surface across every adapter.
Emit static files in CI, or compile in the browser and swap themes live with override() — exactly what this page does.
The design system an AI agent can't drift from: a fixed, named vocabulary plus a machine-readable contract it queries and validates against — installable skills, a versioned manifest, and fail-loud codes. The agent story ▸
Is refract for you?
No tool fits everything — here's the honest read, so you can tell fast.
- you ship one design system to multiple targets (web · SCSS · tokens · a component library) from a single source;
- you do white-label / multi-tenant / per-request theming — runtime
override()is built for it, and it's refract's strongest story (the white-labeling recipe ▸); - you want synthesized OKLCH ramps + composition, not hand-kept scales;
- an AI agent authors your theme and it has to stay consistent.
- it's a solo project happy with hand-written CSS variables — no synthesis or composition to gain;
- you're all-in on a utility-first framework and want to stay in its own token system;
- you only need to exchange tokens between design tools — an interchange format alone is simpler (refract speaks DTCG if you later want both);
- you need production-proven stability today — refract is beta;
- you're native-first — React Native / Compose adapters aren't here yet.
Installation
refract is a small family of packages — a core compiler plus one package per output format. Install the core and just the adapter(s) you need; nothing you don't use lands in your dependency tree (the styled-components peer, for one, only ships with that adapter). The @theme-registry namespace is an intentional home for a family of theming packages — refract is its first member, standalone by design: it depends on none of the others and works entirely on its own.
npm install @theme-registry/refract @theme-registry/refract-css
| Package | What it gives you |
|---|---|
| @theme-registry/refract | Core — createTheme, the format-neutral Model, the refract build CLI, DTCG interop (./dtcg), and the adapter contract (./adapter-kit). Always required. |
| @theme-registry/refract-css | createCssAdapter — CSS custom properties + classes. The stable reference adapter. |
| @theme-registry/refract-scss | createScssAdapter — Sass $variables + classes. |
| @theme-registry/refract-json | createJsonAdapter — the full Model as structured JSON. |
| @theme-registry/refract-styled-components | createStyledComponentsAdapter — TS/JS theme modules. Carries the styled-components peer. |
| @theme-registry/refract-mcp | The Model Context Protocol server — refract-mcp bin. Serves the project's compiled theme as live agent tools + resources. |
import { createTheme } from "@theme-registry/refract"; import { createCssAdapter } from "@theme-registry/refract-css"; const theme = createTheme(raw, { adapter: createCssAdapter() }); // theme.css · theme.tokens · theme.classes · theme.resolveToken() document.head.append( Object.assign(document.createElement("style"), { textContent: theme.css }) );
styled-components is needed only for that adapter, and typescript only if your build config is a .ts file (a .mjs or .js config never loads it). Install typescript@5 explicitly — a bare npm i -D typescript now resolves to 7.x, the native port, whose main entry exports only a version string; the compiler API refract transpiles a .ts config with lives behind its ./unstable/* subpaths and isn't stable yet. Scaffolded projects already pin ^5.Start from a colour, not a blank file
Answer a few questions and get a complete RawTheme — palettes with tonal ladders, semantic colours, a type scale with derived leading, a spacing ramp. Every colour is checked against WCAG contrast before the file is written. The generator runs once; what it produces is an ordinary theme file you own and edit.
1 · Two ways in
| Situation | Command | Writes |
|---|---|---|
| You have a project, it needs a theme | refract create | theme.raw.ts · .js · .json |
| Nothing exists yet | npm create refract-theme | a whole publishable theme package |
| Tokens already exist elsewhere | refract import | see DTCG interop |
The first two run the same interview and the same generator — the difference is only whether a project is created around the result.
2 · The interview
Ten prompts, most of them Enter-through. Every one has a flag, and with no TTY each takes its default instead of blocking — so this is safe in a script or in CI.
npx refract create ? Primary colour › #4c6ef5 parsed · lightness 59% — lands at ≈400 on the ladder ? How many brand colours? › 2 → complement #9a7800 ? Also add › semantic colours · neutral ramp · shadow tints ? Contrast target › WCAG AA ? Base font size › 16 ? Type scale › major-third ? Overall feel › Neutral ? CSS reset › preflight ? Format › theme.raw.ts Contrast · 7 pairings checked primary 4.32 −1 4.51 AA secondary 4.15 −2 4.54 AA success 3.45 −7 4.54 AA warning 6.04 unchanged theme.raw.ts 7 subsystems · 151 tokens · 0 recipes
Or skip the questions entirely:
npx refract create --yes # every default npx refract create --seed "#e64980" --colors 3 \ --scheme triadic --feel editorial # fully scripted
3 · What it writes
One file. Literal where a value came from the generator's judgement, declarative where the engine already synthesizes — so retuning a scale stays a one-word edit instead of a regenerated table:
const ladder = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900]; export const raw = { colors: { // each brand colour is its own palette, with its own ladder primary: { base: "#496bf1", text: "#ffffff", steps: ladder }, secondary: { base: "#937200", text: "#ffffff", steps: ladder }, success: { base: "#09882e", text: "#ffffff", steps: ladder }, /* … info · warning · danger · neutral · shadow … */ }, typography: { // a declaration — the engine synthesizes xs…4xl from these two fontSize: { base: 16, ratio: "major-third" }, // derived per step, and named after the step they were tuned for lineHeight: { base: 1.5, variants: { "4xl": 1.17, "3xl": 1.23, xs: 1.65 } }, }, // linear curve — every stop lands on the 4px grid layout: { spacing: { base: 4, step: 4, steps: { xs: 1, sm: 2, md: 4, lg: 6, xl: 8 } } }, } satisfies RawTheme;
The derived leading and tracking are named after the size step, so the pairing documents itself with no recipe holding it together:
.hero h1 { font-size: var(--dt-typography-fontsize-4xl); line-height: var(--dt-typography-lineheight-4xl); letter-spacing: var(--dt-typography-letterspacing-4xl); }
4 · What it doesn't write
className yet, and that's deliberate: which components you need and how they compose is design work, not something to guess. Turning these tokens into classes is the next step — see Recipes and Components.Also absent by design: no @keyframes (nothing would reference one without a recipe), and no component definitions.
5 · Decisions baked in
Worth knowing before you edit, so you don't undo something deliberate:
| Decision | Why |
|---|---|
| Brand colours 2–5 are top-level palettes | A harmony member is a single flat leaf — you can't build a UI on one. Each derived hue is promoted to its own family with a full ladder. |
base is the brand colour | The ladder is absolute lightness, so the seed lands wherever it falls — often between two stops. Nothing snaps; the hex you typed survives. The 50…900 stops are for surfaces, borders and states. |
| Semantics are hue-anchored | Rotating off the seed would make “danger” whatever lands at +150° — from a red seed, green. The four semantic colours start from fixed hues and borrow only the seed's character. |
| Contrast runs before writing | Every text pairing is scored and failing colours are darkened in OKLCH points until they clear the bar. A mid green at the primary's lightness reads fine as a chip and fails as a button. |
| Spacing uses the linear curve | step: 4 keeps every stop on a 4px grid. The geometric curve gives 8 · 12 · 18 · 27 · 40.5 — right for type, wrong for space. |
base later and re-run refract audit — the palette was generated to clear AA, and it's easy to lose that by hand.6 · Retuning it
| You want | Change |
|---|---|
| A different brand colour | colors.primary.base — every step re-derives |
| A tighter type scale | typography.fontSize.ratio → "major-second" |
| More generous spacing | the multipliers in layout.spacing.steps |
| Softer corners | borders.radius.base |
Don't replace fontSize: { base, ratio } with a hand-listed ladder, or the spacing curve with literal pixels — that discards the intent the engine synthesizes from.
7 · Wiring the build
refract init looks for a theme.raw.* and writes a config that imports it, rather than carrying a starter palette of its own. Run create first, then:
npx refract init # found theme.raw.ts — wired it up npx refract build # → dist/theme/theme.css npx refract audit # 7/7 pass, 0 fail
From here, Quickstart walks the same ground by hand if you'd rather understand the grammar before generating it, and Recipes is where these tokens become classes.
Your first theme
Author a small RawTheme, compile it, and render a themed button — end to end in five steps. Every snippet below is real; the output is exactly what the library emits.
1 · Author a RawTheme
A plain object, one key per subsystem. Here: a brand colour (its tonal steps are synthesized for you) with a solid recipe, and one components recipe that references it.
const raw = { colors: { brand: { base: "#4c6ef5", text: "#ffffff" }, recipes: { solid: { brand: { background: "brand", color: "brand.text", states: [{ state: "hover", background: "brand.dark" }] } } }, }, }, components: { recipes: { buttons: { // compose: reference the colours recipe, add an own style delta // css is literal-first: bare strings are raw CSS; ref() marks a token primary: { colors: "solid.brand", css: { color: ref("colors.brand.text"), cursor: "pointer", border: "none", borderRadius: "8px", padding: "10px 16px", fontWeight: "600" } }, }, }, }, };
2 · Compile it with createTheme
Pass an adapter — the output format. The CSS adapter is the common choice; core ships no default, which is what keeps the Model format-neutral.
import { createTheme } from "@theme-registry/refract"; import { createCssAdapter } from "@theme-registry/refract-css"; const theme = createTheme(raw, { adapter: createCssAdapter() });
3 · Inject the stylesheet
theme.css is the full stylesheet — :root custom properties plus recipe classes. Add it to the document once.
document.head.append( Object.assign(document.createElement("style"), { textContent: theme.css }), );
That theme.css contains — brand steps synthesized, the recipe lowered to a class:
:root { --dt-colors-brand: rgb(76, 110, 245); --dt-colors-brand-text: rgb(255, 255, 255); --dt-colors-brand-light: rgb(114, 148, 255); /* synthesized (OKLCH) */ --dt-colors-brand-dark: rgb(51, 77, 210); /* synthesized (OKLCH) */ } .dt-components-buttons-primary { color: var(--dt-colors-brand-text); /* the "colors.brand.text" ref */ cursor: pointer; border: none; border-radius: 8px; padding: 10px 16px; font-weight: 600; }
4 · Use the class
theme.classes mirrors your recipes — subsystem → group → variant. Composition is a class list (the referenced recipe + the component's own delta), exposed as a ready-to-use className.
theme.classes.components.buttons.primary.className;
// → "dt-colors-solid-brand dt-components-buttons-primary"
<button className={theme.classes.components.buttons.primary.className}>Save</button>
5 · Re-theme live with override()
Derive a child theme by passing only what changes. Untouched branches keep their reference; the parent is left byte-identical — so override() is a real child theme, not a re-run. Swap dark.css in to re-theme.
const dark = theme.override({ colors: { brand: { base: "#93a4ff", text: "#0b1020" } } }); dark.css; // full stylesheet for the child — brand steps re-derived theme.css; // unchanged
theme.override() child swapped in live. Next: the full Theme API · RawTheme anatomy · the subsystems.Decision cheatsheet
Reach for the right tool at a glance:
| I want to… | Use | Where |
|---|---|---|
| Add a dark (or high-contrast) mode | modes | Dark mode |
| Make a whole new brand / child theme at runtime | theme.override(delta) | Theme API |
| Change a value at a breakpoint | responsive + target | Responsive |
| Swap a component to a different variant at a breakpoint | responsive + variant | Variants & targets |
| Reuse one rule-set inside another component | composition (colors: "solid.brand") | Components |
| Guarantee refract loses no cascade fights | the CSS adapter layer option | Precedence |
| Check colour contrast | refract audit / audit(theme) | Contrast audit |
Playground
Edit a RawTheme on the left; it compiles in your browser as you type, and the preview and emitted CSS update live. This is the real library — the same createTheme that drives every panel on this site.
algorithm), which needs a real .ts theme. Everything else is live: change brand.base and the tonal steps re-synthesize, edit a recipe and the class re-emits, add a states.hover and hover the preview. Note a component's css delta is literal-first — a bare string is a raw CSS value; mark a token with the JSON-safe { "ref": "…" } object (the ref() helper's data form).Agent-native
refract is built to be the design system an AI agent can't drift from. An agent doesn't guess your tokens or invent class names — it binds to a fixed, named vocabulary and a machine-readable contract it can read, query, and validate against. The headline is consistency: the same theme, named the same way, every time. (Fewer tokens is a real but secondary bonus — an agent emits a short path like colors.brand.dark, not a hex pasted everywhere.)
Every value has one stable token path (colors.brand.dark) — a public identifier that won't change within a release. An agent references names, not values, so its output stays consistent across turns and files. Token paths ▸
Opt into the self-documenting guide and refract emits llms.txt + a versioned manifest.json — the recipe index plus the full token set, in one file the agent binds to. Self-documenting output ▸
Fourteen procedural skills, version-locked to your refract, teach an agent the authoring workflow — one command installs them into Claude, Copilot, Cursor, Codex and more. AI skills ▸
An invalid theme fails loud with a stable RefractError code, and validation collects every problem at once — so an agent fixes them all in one pass, not one build at a time. Errors ▸
A live query surface
Beyond the static contract, a Model Context Protocol server turns the theme into live tools an agent calls directly — resolveToken, getClass, listRecipes, validateTheme — so it queries and validates against the real compiled theme instead of guessing. It's the one capability a plain token file can't offer. The MCP server, with examples ▸
AI skills
The agent-native surface in practice: refract ships a catalog of AI skills — short, procedural guides that teach a coding agent how to work with the package across the whole workflow: authoring, the token subsystems, adapters, the build, consuming the output, and migration. Install them into your agent with one command, version-locked to the refract you have.
Install
refract skills install writes the skills into your agent's own config. Run it once per project (or --global for your whole machine); re-run update after upgrading refract so the guidance never drifts from your version.
refract skills list # show the catalog refract skills install --agent claude # install for one agent (interactive if --agent omitted) refract skills install --agent all --optional # every agent + the opt-in skills refract skills update # re-sync after upgrading refract
Each agent loads skills differently, so the installer adapts the output per target. Claude gets native per-skill files it loads automatically; every other agent gets a small router in its own instructions file that points at on-demand bodies under .refract/skills/ — so a non-Claude agent doesn't load all fifteen every turn. A .refract/skills.lock manifest records what's installed for a clean update.
| Agent | Where the skills land |
|---|---|
| claude | Native per-skill files at .claude/skills/<name>/SKILL.md, loaded automatically. |
| codex · opencode · generic | An AGENTS.md router + on-demand bodies under .refract/skills/. |
| github-copilot | A .github/copilot-instructions.md router + .refract/skills/ bodies. |
| cursor | A .cursor/rules/refract-skills.mdc router + .refract/skills/ bodies. |
Flags mirror the interactive prompts: --agent <a,b|all>, --global / --local (default local, to the project), --only <names> for a subset, and --optional to include the opt-in tier.
The catalog
Fifteen skills, hub-and-spoke: theme-authoring is the hub that orients and routes; the rest are focused spokes that own their vocab once so they never drift. Thirteen are core (installed by default); two are opt-in. Wording is agent-neutral, so they read as plain reference files too.
| Skill | Tier | Covers |
|---|---|---|
| theme-authoring | core | The hub — orient, frame a RawTheme, and route to the right spoke. |
| theme-foundations | core | Cross-cutting vocab: breakpoints, containers, responsive, variant/target, modes, states, refs. |
| recipes-and-composition | core | Recipes, recipe variants, and composing across subsystems. |
| colors | core | Palettes, OKLCH synthesis, tonal steps, harmony, colour-math. |
| typography | core | Families, weights, the modular type scale. |
| layout | core | Spacing / gutters / sizes scales; columns / grids / stacks / containers. |
| visual-effects | core | Effects, borders, and animation. |
| adapter-usage | core | Choosing an adapter and its options; installs the chosen package. |
| build-config | core | theme.config / defineConfig, targets, emit modes, the CLI, dark mode. |
| consuming-the-output | core | Wiring the emitted output per adapter and framework. |
| overrides-and-child-themes | core | theme.override() — child themes and white-labeling. |
| theme-scaffold | core | Generating a starting theme from one seed colour — refract create and npm create refract-theme. |
| dtcg-import | core | Migrating in from a DTCG tokens.json. |
| adapter-scaffold | opt-in | Writing your own adapter with defineAdapter. |
| troubleshooting | opt-in | Mapping a validation error to its fix. |
MCP server
A Model Context Protocol server that exposes your project's theme as live tools an agent calls directly — so it answers "what class do I use here?" against the real compiled theme, and validates its own work as it goes. It's the one capability a plain token file can't offer.
theme.config.(ts|js|mjs) once at startup (auto-discovered in the cwd, or via --config) and holds it — so the query tools take no theme argument; the agent asks about the loaded theme without resending it. It reloads on change, and runs on the official @modelcontextprotocol/sdk over stdio. Published on the npm latest tag, versioned in lockstep with core and the adapters. A .ts config needs the typescript optional peer at 5.x (same as refract build — see Installation).The tools
Eleven read/validate tools over the loaded theme:
| Tool | Arguments | Answers |
|---|---|---|
| resolveToken | { path } | a token path's value + its CSS varName, unit, and derivedFrom |
| listTokens | { } | every token path — the addressable vocabulary |
| findToken | { prefix } | token paths that start with a prefix (discover names) |
| searchTokens | { query } | tokens matching a query on their path OR resolved value |
| listRecipes | { } | every recipe as { subsystem, group, variant } |
| getClass | { subsystem, group, variant } | the real class + composed class-list for a recipe (configured prefix) |
| renderRecipe | { subsystem, group, variant } | the exact CSS one recipe emits |
| checkContrast | { minWcag? } | WCAG-2 contrast audit of the theme's pairings (+ advisory APCA) |
| validateTheme | { theme? } | validate a candidate edit against every target — every problem at once (collect-all), per target |
| diffTheme | { theme } | the blast radius of a candidate vs the loaded theme — tokens moved, classes changed, pairings crossed, targets failing |
| reload | { } | reload the project's theme config from disk |
refract://llms.txt (prose) and refract://manifest.json (a machine index, schema 1, with real class names + DTCG tokens) — the same self-documenting guide refract build --guide emits, rendered live from the loaded theme. Query tools take no theme argument (they read the loaded project); validateTheme / diffTheme take a candidate.diffTheme has a CLI + CI twin. The same blast-radius diff runs without an MCP server — refract diff <candidate> — and gates a PR on how much a change moves. It's a headline capability with its own page: Blast radius — refract diff ▸.Connect
Register the server with your agent — any MCP client works the same way over stdio. Once published:
claude mcp add refract -- npx -y @theme-registry/refract-mcp
…or from a local build (it auto-discovers theme.config.* in the cwd; pass --config to point elsewhere):
pnpm --filter @theme-registry/refract-mcp build # → dist/server.js claude mcp add refract -- node ./packages/refract-mcp/dist/server.js
For project scope, commit a .mcp.json — anyone who opens the repo gets an agent wired to that theme:
{ "mcpServers": { "refract": {
"command": "npx",
"args": ["-y", "@theme-registry/refract-mcp", "--config", "theme.config.ts"] } } }
Examples
Ask for a class. The agent calls getClass — no theme in the call, it's the loaded one — and gets the exact class-list to put on an element:
// → request { "method": "tools/call", "params": { "name": "getClass", "arguments": { "subsystem": "components", "group": "buttons", "variant": "primary" } } } // ← result (inside the MCP content block) { "className": "dt-components-buttons-primary", "classList": ["dt-colors-solid-brand", "dt-components-buttons-primary"] }
Validate before writing. validateTheme returns every problem in one pass with a stable code — the agent fixes them all, then proceeds:
// → validate a theme with a bad colour { "name": "validateTheme", "arguments": { "theme": { "colors": { "x": { "base": "nope" } } } } } // ← result { "ok": false, "code": "REFRACT_E_COLOR_INPUT", "errors": ["Invalid colour \"nope\". Author a colour as a hex string (\"#4dabf7\"), …"] }
Resolve a value. When an agent needs a literal (a chart colour, an inline style), resolveToken hands back exactly what the CSS variable holds:
// → { path: "colors.brand.dark" } // ← result { "path": "colors.brand.dark", "value": "rgb(51, 77, 210)" }
getClass + validateTheme can't drift: it reads real names and its work is checked against the real compiler at every step. Pair it with DTCG import — pull a design's tokens from a Figma (or Style Dictionary) export, author a theme from them, and let the server keep the agent honest — for a design-to-code loop that can't invent values. See Agent-native for the whole story.Blast radius — refract diff
Every value in a refract theme is a { ref, fn, arg } graph, not a frozen literal — so refract can answer a question a plain token file can't: what will this change break, before I ship it? refract diff <candidate> builds a candidate theme against your config's targets and prints exactly what moves — tokens re-valued, recipe classes changed, and any contrast pairing that crossed a threshold. It's the most defensible thing refract does: nothing else in the category stores the reference graph, so nothing else can tell you the blast radius up front.
On the command line
Point it at a candidate theme — a theme.config.* or a DTCG document. With no thresholds it just reports the diff; add thresholds and any breach exits nonzero, turning it into a PR gate.
refract diff candidate.ts # print the blast radius (report only) refract diff candidate.ts \ --max-token-changes 20 --max-class-changes 5 --fail-below AA # gate: fail the run on a breach
The gate flags: --max-token-changes <n> and --max-class-changes <n> fail when more than n tokens or recipe classes move; --fail-below <AAA|AA|AA-large> fails when any pairing drops below that WCAG-2 level. Point at a non-default config with --config <path>.
As a CI gate — GitHub Actions
Drop this in .github/workflows/theme-diff.yml. On every pull request it diffs the candidate theme against your committed config and fails the check if a change moves more than the thresholds allow or drops a colour pairing below AA — so a token tweak can't silently re-skin the app or break contrast.
name: theme-diff on: pull_request jobs: diff: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: 22 } - name: Theme blast-radius gate run: > npx -y @theme-registry/refract diff candidate.ts --max-token-changes 20 --max-class-changes 5 --fail-below AA
refract diff --fail-below gates changes; refract audit --strict gates the absolute contrast of the whole theme. Both exit nonzero, so both slot into the same job.Programmatic
The same diff is a plain export for custom tooling or a dashboard: diffThemes(base, candidate) from @theme-registry/refract/build returns the { tokens, classes, contrast, summary } structure the CLI and the MCP diffTheme tool both render.
import { diffThemes } from "@theme-registry/refract/build"; const { tokens, classes, contrast, summary } = diffThemes(base, candidate); // summary → { tokenChanges, classChanges, worstPairing } for your own gate
diffTheme tool too, so an agent can check its own change's blast radius before applying it — plan-then-apply, not apply-then-discover. See MCP server.createTheme & the Theme
createTheme(raw, options) is the one entry point. It normalizes your RawTheme into the format-neutral Model, binds the adapter, and returns a Theme — the object you read output and tokens from.
createTheme(raw: RawTheme, options: { adapter: ThemeAdapter; media?: MediaConfig; units?: UnitsConfig; baseFontSize?: number }): Theme
Options
| Option | Type | Required | Description | |
|---|---|---|---|---|
| adapter | ThemeAdapter | Required | The output target — createCssAdapter() / createScssAdapter() / … Core ships no default (that's what keeps the Model format-neutral). For a token-only consumer that reads theme.tokens/resolveToken/model and renders nothing, pass the built-in createNoopAdapter(). | |
| media | { unit?: "px"|"em"|"rem"; baseFontSize? } | Optional | Unit for @media / @container thresholds (breakpoints are authored in px; default px). Distinct from units, which governs declaration values. Stable across override() children. | |
| units | UnitsConfig | Optional | Length units for declaration values (§21). A token-path role map — units.default (global), units["<subsystem>"], units["<subsystem>.<property>"]; most-specific wins, over a built-in seed (length subsystems → px, lineHeight → none, letterSpacing → em). Resolved once, format-neutrally, onto every length leaf — every adapter then emits the same unit. See Length units. | |
| baseFontSize | number | Optional | 16 | Divisor when a deferred length resolves to rem (§21). Distinct from media.baseFontSize. |
| propertiesOverlay · ruleSetsOverlay · keyframesOverlay · containersOverlay | DTCG round-trip | Optional | Prebuilt Model slices spliced in after the standard build, to restore the lossless bits a resolved DTCG token surface can't carry — appearance modes / responsive / derivation refs, and whole recipes/keyframes/containers. Set by fromDTCGTheme from the com.theme-registry.refract extension; rarely set by hand. Absent → the standard build is untouched. |
The Theme surface
The base surface is format-neutral; the adapter adds its own output getters via extend (the CSS adapter adds css / variablesCss / recipesCss / nodes / classes).
| Member | Type | What it is |
|---|---|---|
| model | ThemeModel | The held state — the single format-neutral source of truth. |
| tokens | Record<string, Ref> | Flat, lazy, cached path → Ref map of property tokens. Aliases / derived steps stay as refs ({ ref, fn, arg }), so it's override-safe. |
| resolveToken | (path) => Literal | Resolve one token path to its concrete value — following aliases and running derivations (lighten/darken/…). Throws on an unknown path. |
| override | (partial) => Theme | Derive a child theme (delta merge — see below). |
| css / classes / … | adapter-added | The adapter's output surface (CSS adapter: css, variablesCss, recipesCss, nodes, classes). |
theme.override() — child themes
A delta merge, not a re-run. Only the partial's changed subsystem slices are re-normalized and immutably merged into the current Model at property / rule-set-variant granularity; untouched branches keep their reference. The parent Model (and its bound output) is left byte-identical — so it's a real child theme. Overriding a colour base re-derives its synthesized steps for free.
const theme = createTheme(raw, { adapter: createCssAdapter() }); // a dark child — only the touched slices re-normalize; parent untouched const dark = theme.override({ colors: { brand: { base: "#8aa2ff", text: "#0b1020" } } }); dark.css; // a full stylesheet for the child theme.css; // unchanged
theme.override() child of Halcyon, swapped in live.RawTheme anatomy
The RawTheme is a plain object — one top-level key per subsystem, nothing else. Every key is optional: emit only the subsystems you author. There is no adapter-specific syntax anywhere in it — the same object drives every adapter.
const raw: RawTheme = { colors: { /* palettes → synthesized tonal steps */ }, typography: { /* type scale, families, weights, headings */ }, effects: { /* shadow, transitions, opacity, blur, z-index */ }, borders: { /* width, style, radius, offset + edge recipes */ }, animation: { /* durations, easings, keyframes, motion recipes */ }, layout: { /* spacing, sizes, columns, grids, stacks, containers */ }, components: { /* recipes only — compose the subsystems above */ }, globals: { preset: "preflight" /* + themed element rules */ }, };
Every subsystem slice shares one shape
Learn the pattern once and it holds across all of them. A slice is an open map of properties, plus a reserved recipes block. A property is either a bare value or an extended property:
| Key | Shape | Meaning |
|---|---|---|
| <property> | value | ExtendedProperty | A design value — a token. space: 8 or the extended form below. |
| base | value | The property's own value, in extended form. |
| variants | Record<name, value> | Named siblings of the property (sm/lg, light/dark) — each its own token. |
| responsive | [{ at, … }] | Per-breakpoint overrides — lowered to @media / @container var overrides by core. |
| modes | Record<mode, value> | Values swapped by an active mode (e.g. a dark mode scope). |
| recipes | RecipeBlock | Reserved. Named rule-sets → the classes the adapter emits. Values are token paths, not literals. |
"colors.brand", "borders.edge.card"); it does not inline the value. That's why override() a base value re-flows everything that referenced it — and why a property reference renders as var(--…), never a frozen literal.Properties vs. recipes
Two things every subsystem emits, and the split is the whole mental model:
| Properties | Recipes | |
|---|---|---|
| what | Raw design values — the vocabulary. | Named rule-sets that use the vocabulary. |
| emits | CSS custom properties — --dt-<sub>-<path>. | Classes — .dt-<sub>-<group>-<variant>. |
| values | Literals or refs to other properties. | Token paths only (refs), never literals. |
components is the special case: it declares recipes only — its whole job is to compose properties and recipes from the other subsystems into one class. See Components.
RawTheme — the four presets that drive this site are four such objects. Start with Colors ▸Build-time
Compile once in your build or CI and ship static files — plain CSS, tokens, SCSS. Zero runtime: the browser just loads the stylesheet, and refract never reaches the client bundle.
The CLI reads a theme.config.ts, runs each target's adapter, and writes the emitted files to disk. This layer uses Node (fs, TypeScript) — it runs at build, not in the browser.
import { defineConfig } from "@theme-registry/refract/build"; import { createCssAdapter } from "@theme-registry/refract-css"; import { createScssAdapter } from "@theme-registry/refract-scss"; import { raw } from "./theme.raw"; export default defineConfig({ raw, targets: [ { name: "css", adapter: createCssAdapter(), outDir: "dist/theme" }, { name: "scss", adapter: createScssAdapter(), outDir: "dist/scss" }, ], });
defineConfig(config) — config reference
| Property | Type | Required | Default | Description |
|---|---|---|---|---|
| raw | RawTheme | Required | — | The theme you author — the single source compiled for every target. |
| targets | EmitTarget[] | Required | — | One or more output targets. A single theme can emit CSS + SCSS + styled-components at once. |
| media | MediaConfig | Optional | { unit: "px" } | Media-query output unit for @media + @container widths — { unit: "px" | "em" | "rem"; baseFontSize?: number } (thresholds are authored in px; baseFontSize defaults to 16). |
| units | UnitsConfig | Optional | px seed | Length units for declaration values (§21) — the build-time twin of createTheme's units, so refract build emits the same unit to disk. A token-path role map (units.default, units["<subsystem>"], units["<subsystem>.<property>"]; most-specific wins over the built-in px seed). Resolved once, format-neutrally, so every target emits the same unit. See Length units. |
| baseFontSize | number | Optional | 16 | Divisor when a deferred length resolves to rem (§21). Distinct from media.baseFontSize. |
EmitTarget — target reference
| Property | Type | Required | Default | Description |
|---|---|---|---|---|
| adapter | ThemeAdapter | Required | — | The constructed adapter that renders this target — e.g. createCssAdapter(). The import is the extensibility seam; pass adapter options at construction. |
| outDir | string | Required | — | Directory the emitted files are written to, relative to the config. |
| name | string | Optional | index / outDir | A label for the target; lets refract build --target <name> select just this one. |
| emit | Emit | Optional | "single" | Output-shape directive — how the files are written (single / split / subsystem / components). See the emit API below. |
| helpers | string[] | Optional | [] | Vendored, self-contained runtime helper modules to also emit alongside the CSS. Currently "color-math" — the lighten/darken module for live colour math in the browser. |
| guide | boolean | GuideConfig | Optional | false | Emit a self-documenting llms.txt + manifest.json into outDir so the shipped theme carries its own AI consumption guide — real class names / export ids / token paths — for a downstream dev who has neither refract nor its skills. true uses defaults; an object tunes file names or adds packageName for a by-specifier import overlay. See Self-documenting output. |
| preview | boolean | PreviewConfig | Optional | false | Emit a human-facing preview.html specimen into outDir — token plates for any adapter, plus live recipe plates when the output is browser-loadable (CSS). true uses defaults; an object tunes { file, title, inline }. See Human-facing preview. |
refract create # design a theme.raw.(ts|js|json) from one seed colour refract init # scaffold theme.config.ts (imports theme.raw.* if present) refract import tokens.json # seed theme.raw.ts (+ config) from a DTCG tokens.json refract build # compile every target → write files refract tokens --out tokens.json # DTCG export (adapter-free) refract audit --strict # score colour contrast (WCAG + APCA); fail on a miss refract skills install # install the AI skills into your agent (claude/codex/…)
:root { --dt-colors-brand: #4c6ef5; --dt-colors-brand-dark: #334dd2; } .dt-colors-solid-brand { background: var(--dt-colors-brand); color: var(--dt-colors-brand-text); }
emit picks how it's written — one file, split (styles + variables), per-subsystem, or self-contained per-components. Ship the precompiled output; nothing from refract runs in production.The emit API — output modes
Set emit on any target. Four modes; every field is optional and defaults are filled by the build layer. The CSS adapter is the reference realization — other adapters honor a subset and throw a clear error on a mode they don't map.
| Mode | type | Writes | Description |
|---|---|---|---|
| single | "single" · default | theme.css | One file — every subsystem's :root variables plus all rules. The global adapter inline option bakes resolved values whole-file. |
| split | "split" | styles.css · variables.css | Two files: all rules + all :root variables. Load-order contract — variables must load first, no @import; global inline is rejected. |
| subsystem | { type: "subsystem" } | <sub>.css · <sub>.variables.css × N | A styles + variables pair per subsystem. Same load-order contract; components owns no properties → emits styles only. |
| components | { type: "components" } | ${group}-${variant}.css (+ variables.css) | The only mode that flattens each variant into one self-contained rule-set — referenced recipes' declarations + own css delta (delta wins). |
Options per mode
| Mode | Option | Type | Required | Default | Description |
|---|---|---|---|---|---|
| single | file | string | Optional | "theme.css" | The output filename. |
| split | file | string | Optional | "styles.css" | The rules file. |
| split | variables | string | Optional | "variables.css" | The :root vars file. Its mere presence infers type: "split". |
| subsystem | filename | (subsystem, kind) => string | Optional | <sub>.css / <sub>.variables.css | Names both files of each pair. kind is "styles" | "variables". |
| components | inline | boolean | Optional | true | Bake literal values (zero var(, fully portable). false → var(--…) refs plus a tree-shaken variables file. |
| components | filename | (c) => string | Optional | `${group}-${variant}.css` | File assignment. Variants returning the same name are concatenated — () => "components.css" collapses all into one. |
| components | variables | string | false | Optional | "variables.css" | The tree-shaken vars filename (used when inline: false); false suppresses it (the consumer supplies the vars). |
single / split may omit type — a bare { variables } infers split; subsystem and components always need an explicit type (or the string shorthand). The refract skills subcommand (also on this CLI) is documented under AI skills.Self-documenting output — guide
Set guide: true on any target and refract writes two extra files into that target's outDir: an llms.txt narrative and a manifest.json index. They name the theme's real identities — the emitted class names / export ids / dotted token paths — so a developer (or their coding agent) can consume the theme from the folder alone, with no refract, no skills, and no guessing at names. The files travel inside outDir, so they accompany the theme however it ships: an npm package, a CI zip, or a vendored folder.
export default defineConfig({ raw, targets: [ { name: "css", adapter: createCssAdapter(), outDir: "dist/theme", guide: { packageName: "@acme/theme" }, // or just `guide: true` }, ], });
# Theme consumption guide (css) This folder is a self-contained theme built with refract. You do NOT need refract to use it … ## How to use Import the stylesheet once, then apply the recipe class names below to your elements. Example: `import "./theme.css";` then `<button class="<class>">`. If this theme is installed as the `@acme/theme` package, you may import the same files by specifier (e.g. `@acme/theme/theme.css`) instead of by relative path. ## Recipes — real names | Recipe | Identity | | --------------------------- | ------------------------------------------------- | | `colors.solid.brand` | `dt-colors-solid-brand` | | `components.buttons.primary`| `dt-colors-solid-brand dt-components-buttons-primary` |
ThemeProvider + the tree-shakeable css export ids, SCSS names the @use partials + class names, JSON names the dotted ruleSets keys. manifest.json pairs the recipe index with the theme's full token set (DTCG), and carries a schema version (currently 1) so an agent can bind to a known contract shape. Off by default — a build with no guide is byte-identical.Human-facing preview — preview
guide writes for an agent; preview writes for a person. Set preview: true on any target and refract writes a preview.html into that target's outDir — a rendered specimen of the theme you double-click, forward, or hand a designer. By default it inlines its stylesheets, so the page is a single self-contained file that keeps working after it's moved out of outDir.
export default defineConfig({ raw, targets: [ { name: "css", adapter: createCssAdapter(), outDir: "dist/theme", preview: true, // or { file, title, inline: false } }, ], });
It reads as a proper style guide rather than a token dump. The page is a light paper and each plate a card: a masthead in your theme's own first palette with headline counts (including a WCAG pass ratio across declared pairings), an index whose entries are the shape of the theme, one card per colour family with a large base swatch and its lightness ladder, the type ramp set in its own sizes, spacing shown as a measure and as an applied inset (there is no separate padding token — spacing is the padding scale), a state matrix per recipe, and a copy-on-click identifier beside every specimen. Sections appear only when the theme has tokens of that kind.
Two things follow from what refract knows. Each value is tagged src or gen — whether you authored it or refract synthesised it — read from the model, since the token export resolves both to plain literals. And the sheet is deliberately light-only: colour can't be judged against a moving backdrop, and a flipping page would make it impossible to tell whether a swatch changed because the theme's mode changed or because the page did. The appearance control moves the specimen, not the sheet.
The page has two halves, and they degrade independently:
| Half | Source | Works with |
|---|---|---|
| Token plates | The format-neutral token export — colours, the type ramp, spacing, radii, shadows, borders, breakpoints, each with its exact value. | Every adapter, including third-party ones. Nothing about the emitted format is involved. |
| Recipe plates | The emitted stylesheet, loaded into the page, with each recipe rendered on its real class list. | Adapters whose output a browser can load as-is — CSS today. |
For a target whose output isn't browser-loadable, the page still renders every token and still lists every recipe by its real identity — it just says, in that adapter's own words, why it can't render them live. SCSS needs compiling first; styled-components emits JS modules that need React; JSON is data with no rendered form. For a live design-review page from a styled-components or SCSS theme, add a CSS target to the same config — it compiles the same recipes through the same core, so it's a faithful specimen.
emit mode. It references the files that were actually written — split loads the variables file before the styles file, subsystem loads every pair and groups the plates by subsystem, and components groups them by the file each component landed in and marks the recipes that mode doesn't emit. A theme with appearance modes gets a light/dark toggle; one with breakpoints gets frame-width buttons. Off by default — a build with no preview is byte-identical.Run-time
Compile in memory in the app or browser and use the output live — inject theme.css, apply theme.classes, read theme.resolveToken(), and derive child themes with theme.override().
The runtime graph is browser-safe (no Node deps), so createTheme runs client-side. That unlocks live theming, user-editable themes, and per-request themes on the server.
import { createTheme } from "@theme-registry/refract"; import { createCssAdapter } from "@theme-registry/refract-css"; const theme = createTheme(raw, { adapter: createCssAdapter() }); // inject once, then use the emitted classes document.head.append(Object.assign(document.createElement("style"), { textContent: theme.css })); button.className = theme.classes.components.buttons.primary.className;
// derive a CHILD theme — only the delta is re-normalized, parent untouched const dark = theme.override({ colors: { brand: { base: "#8aa2ff", text: "#0b1020" } }, }); document.head.append(Object.assign(document.createElement("style"), { textContent: dark.css }));
theme.css, and “Halcyon Noir” is a theme.override() child of Halcyon.DTCG interop
DTCG — the Design Tokens Community Group format — is the standard interchange for design tokens; Figma, Style Dictionary and the wider token ecosystem speak it. refract speaks it both ways. It is not an adapter: it's a small, format-neutral interop boundary at the @theme-registry/refract/dtcg subpath, so a Node / build script can use it without pulling the runtime graph.
toDTCG emits your colour, type, spacing and other property tokens (synthesized steps included), not your rule-sets. Tokens survive a trip through any DTCG tool; recipes are refract-specific and stay in the RawTheme.| Direction | Function | What it is |
|---|---|---|
| Import | fromDTCG(doc) | A transform into a RawTheme — you then call createTheme. |
| Export | toDTCG(theme) | A built theme's property tokens out as a DTCG document (also the refract tokens CLI). |
| Resolve | parseDTCGDocument(doc) | Flatten a DTCG doc, resolving its { ref } chains → resolved tokens. |
Import — DTCG → RawTheme
fromDTCG returns a plain RawTheme input you pass to createTheme — and can edit first. Import is a build-time transform, not a runtime step: DTCG groups are mapped to subsystems (auto-detected from $type and group name by default).
import { fromDTCG } from "@theme-registry/refract/dtcg"; import { createTheme } from "@theme-registry/refract"; import { createCssAdapter } from "@theme-registry/refract-css"; const raw = fromDTCG(dtcgDoc); // a RawTheme input — edit it freely const theme = createTheme(raw, { adapter: createCssAdapter() });
| Option | Type | Description |
|---|---|---|
| groupMapping | Record<string, "colors"|"typography"|"effects"|"borders"|"layout"|"ignore"> | Map a DTCG group to a subsystem (or "ignore"). Default: auto-detect from $type + name. |
| breakpoints | Record<string, number> | Breakpoints as name → px — DTCG has no breakpoint type, so supply them here. |
| breakpointGroup | string | A DTCG group path to source breakpoints from (its dimension tokens are parsed to px). |
Export — Theme → DTCG
toDTCG walks the flat theme.tokens map, resolves each path to a concrete literal (following aliases and running lighten/darken), and re-groups into DTCG. Property tokens only — recipes and composition are out of standard scope; colours emit as hex (the DTCG convention); synthesized tonal steps are included.
Recipe round-trip (opt-in). toDTCG(theme, { includeRecipes: true }) additionally stashes the built rule-set IR under a reverse-DNS $extensions key — com.theme-registry.refract (with a version) — so a document can round-trip refract → DTCG → refract without losing recipes. Restore it with fromDTCGTheme(doc, { adapter }) (property tokens and recipes in one call), or read the raw payload with readRefractExtension(doc). This is refract-specific and not portable: other DTCG tools ignore the extension and see property tokens only, and standard output is byte-identical when the flag is off.
import { toDTCG } from "@theme-registry/refract/dtcg"; const doc = toDTCG(theme, { name: "acme" }); // a DTCG document // …or at build time, the CLI sugar over the same call: // $ refract tokens --out tokens.json
{
"$name": "acme",
"color": {
"$type": "color",
"brand": {
"base": { "$value": "#4c6ef5" },
"light": { "$value": "#708bf7" }, // synthesized, resolved to hex
"dark": { "$value": "#3d58c4" },
"text": { "$value": "#ffffff" }
}
},
"breakpoint": { "$type": "dimension", "md": { "$value": "768px" } /* … */ }
}
| Option | Default | Description |
|---|---|---|
| name | — | The document's $name. |
| includeBreakpoints | true | Emit breakpoints as a dimension group. |
Supported token types
Import and export cover the DTCG $types that map onto refract's subsystems:
color · dimension · fontFamily · fontWeight · duration · cubicBezier · number · typography · shadow · border · transition · strokeStyle · gradient
fromDTCG / toDTCG, with no adapter involved. Use the JSON adapter to consume a theme in an app; use DTCG to exchange tokens with the design-token ecosystem.Dark mode
Dark mode is a property mode, not a second theme. Author a dark value on the properties that change; the CSS adapter emits both an OS-preference @media block and a manual [data-theme] toggle from the same variable names — so it follows the system with zero JavaScript, and a one-line attribute forces it.
1 · Author the mode
Put modes: [{ mode: "dark", … }] on the properties that differ — usually a handful of surface / ink / border colours. Everything that references those tokens flips for free; you don't mode every property.
const raw = { colors: { surface: { base: "#ffffff", modes: [{ mode: "dark", base: "#0b0d12" }] }, ink: { base: "#0e1117", modes: [{ mode: "dark", base: "#e9ecf3" }] }, recipes: { solid: { panel: { background: "surface", color: "ink" } } }, }, };
2 · What the CSS adapter emits
Each moded property is redeclared under two selectors — the same --dt-… names, so one redefinition flips every downstream var(--…) through the cascade:
:root { --dt-colors-surface: rgb(255, 255, 255); --dt-colors-ink: rgb(14, 17, 23); } @media (prefers-color-scheme: dark) { /* follows the OS — no JS */ :root { --dt-colors-surface: rgb(11, 13, 18); --dt-colors-ink: rgb(233, 236, 243); } } :root[data-theme="dark"] { /* manual toggle — wins over the OS block */ --dt-colors-surface: rgb(11, 13, 18); --dt-colors-ink: rgb(233, 236, 243); } .dt-colors-solid-panel { background: var(--dt-colors-surface); color: var(--dt-colors-ink); }
dark and light are the first-class modes — they get the OS-preference @media block. A custom mode (say hc for high-contrast) has no OS signal, so it emits only the :root[data-theme="hc"] block — a manual toggle.3 · Toggle at run-time
Do nothing and it follows the OS. To let the user override, set data-theme on the root element — its higher specificity wins over the media block. Remove it to fall back to the OS.
const root = document.documentElement; root.setAttribute("data-theme", "dark"); // force dark root.setAttribute("data-theme", "light"); // force light root.removeAttribute("data-theme"); // back to the OS preference
data-theme, or leave it off to follow your system.modes vs override()
Both give you a dark UI; they solve different problems.
modes | override() | |
|---|---|---|
| what | A per-property variant baked into one stylesheet. | A separate child Theme object (its own stylesheet). |
| toggle | CSS cascade — flip data-theme, or follow the OS. No recompile, no JS needed for OS. | Swap the stylesheet / re-read child.css in app state. |
| use when | Dark is a variation of the same theme (the common case). | You need a genuinely different theme — distinct presets, programmatic access, run-time-computed values. |
They compose: an override() child still emits its own modes blocks. Reach for modes first for dark mode; reach for override() when you need a distinct theme object.
modes shape and Colors for authoring colour values. Modes work on any property subsystem, not just colours.White-labeling
One product, N brands. Author one base theme, then derive each brand as an override() child. Every brand shares the base's recipe structure, so the emitted class names are identical — only the variable values differ. Switching brands is a stylesheet swap; your markup never changes.
1 · One base, many brands
Author the shared structure once. Each brand overrides only what differs — usually a brand colour or two; the tonal steps re-synthesize from the new base for free.
const base = createTheme(raw, { adapter: createCssAdapter() }); const acme = base.override({ colors: { brand: { base: "#e8590c", text: "#ffffff" } } }); const globex = base.override({ colors: { brand: { base: "#2f9e44", text: "#ffffff" } } });
override() derives a child of a theme you built. To build on top of a published theme instead — reusing its variables without redefining them — declare the borrowed tokens as external: colors: { brand: { external: "colors.brand" } } with a top-level extends: { prefix } emits var(--dt-colors-brand), referenceable everywhere and never redefined. Literal form { external: "--any-var" } points at any parent (Material / Tailwind / hand-rolled). See the authoring guide.2 · Same classes, different values
The composition is structural, so every brand resolves to the same class list. Only each brand's stylesheet redefines the variables — which means your components are brand-agnostic:
acme.getClass("components","buttons","primary") === base.getClass(...); // → true // every brand: "dt-colors-solid-brand dt-components-buttons-primary" // only the emitted variable differs per brand: // base.css → --dt-colors-brand: rgb(76, 110, 245); // acme.css → --dt-colors-brand: rgb(232, 89, 12); // globex.css → --dt-colors-brand: rgb(47, 158, 68);
3 · Serve the right brand
Because markup is identical, delivering a brand is just choosing its stylesheet:
| Where | How |
|---|---|
| Run-time | Hold the active brand in state and inject brand.css — the React swap pattern. |
| Per-request (SSR) | Pick the brand from the request (subdomain / tenant) and stream that brand's <style>. |
| Build-time | Emit each brand to its own file (acme.css, globex.css) and serve per tenant — see Build-time. |
Why override(), not N full themes
override() is a delta merge, not a re-run: only the changed subsystem slices re-normalize, everything untouched keeps its reference (structural sharing), and the base is left byte-identical. So spinning up dozens of brands off one base is cheap — each is a thin delta, not a full theme rebuild.
Migrating CSS variables
Already have a hand-written :root of custom properties and a few utility classes? Moving to a RawTheme is mostly re-homing: variables become properties (grouped by subsystem), and your styled classes become recipes. In return you get tonal synthesis, composition, override(), and multi-format emit.
Before — hand-written CSS
:root { --color-brand: #4c6ef5; --color-ink: #0e1117; --radius: 8px; } .btn { background: var(--color-brand); color: #fff; padding: 8px 14px; border: none; border-radius: var(--radius); cursor: pointer; }
After — a RawTheme
Map each variable to a property under its subsystem; turn .btn into a colours recipe plus a component recipe that composes it. Raw values move into the css delta as bare literals; token references there use ref("…").
const raw = { colors: { brand: { base: "#4c6ef5", text: "#ffffff" }, // text = the on-colour ink: { base: "#0e1117" }, recipes: { solid: { brand: { background: "brand", color: "brand.text" } } }, }, borders: { radius: { base: 8 }, recipes: { edge: { button: { radius: "base" } } } }, components: { recipes: { buttons: { primary: { colors: "solid.brand", borders: "edge.button", css: { padding: "8px 14px", border: "none", cursor: "pointer" }, } } } }, }; // theme.getClass("components","buttons","primary") // → "dt-colors-solid-brand dt-borders-edge-button dt-components-buttons-primary"
brand now synthesizes light/dark steps you can reference; the button is composition (swap colors: for a whole reskin); and the same theme emits SCSS / JSON / styled-components. Migrate incrementally — a bare colors slice already produces useful :root vars. See RawTheme anatomy.Theming a component library
refract contributes two things — CSS variables (tokens) and classes (recipes). How you theme a third-party library depends on which one it consumes.
Libraries driven by CSS variables
Most modern libraries expose their design tokens as CSS custom properties (--lib-primary, …). Point them at refract's tokens with a thin bridge — refract owns the values, the library keeps its API:
/* map the library's variable names → refract's tokens */ :root { --lib-primary: var(--dt-colors-brand); --lib-on-primary: var(--dt-colors-brand-text); --lib-radius: var(--dt-borders-radius-base); }
Because these are var() references, the library re-themes automatically when refract's tokens change — including dark mode and override(). If the library lets you set its variable prefix, you can even set refract's adapter prefix to match and skip the bridge.
Libraries driven by their own classes
If a component ships fixed styles under its own classes, refract's classes can't restyle it from outside (its own CSS wins). Two options: feed the library's theme API with resolved values via theme.resolveToken("colors.brand"), or wrap the component and apply a refract class to your element around it.
resolveToken returns a frozen literal (see the React inline-styles note), so a value you pass to a library's JS theme API won't auto-flip; re-read it when the theme changes.DTCG round-trip
Design tools and token pipelines speak DTCG. The full loop: bring tokens in from Figma / Style Dictionary, compile a theme, and (optionally) send tokens back out — all through the ./dtcg interop boundary.
import { fromDTCG, toDTCG } from "@theme-registry/refract/dtcg"; import { createTheme } from "@theme-registry/refract"; import { createCssAdapter } from "@theme-registry/refract-css"; // 1 · IN — a DTCG export (Figma / Style Dictionary) → a RawTheme input const raw = fromDTCG(dtcgFromDesign); // 2 · enrich — add what DTCG can't carry: recipes, states, composition raw.colors.recipes = { solid: { brand: { background: "brand", color: "brand.text" } } }; // 3 · compile — a real theme, any adapter const theme = createTheme(raw, { adapter: createCssAdapter() }); // 4 · OUT — property tokens back to DTCG for the design tool (or `refract tokens`) const doc = toDTCG(theme, { name: "acme" });
refract → DTCG → refract trip via the opt-in includeRecipes extension + fromDTCGTheme — they just aren't portable to other tools. Full option tables are on the DTCG interop page.React
refract is framework-agnostic — there's no React binding to install. With the CSS adapter you inject theme.css once and apply theme.classes.*.className. Here's the idiomatic wiring: a provider, class usage, and switching override() children in state.
1 · Load the stylesheet & provide the theme
theme.css is the entire stylesheet as a string — the :root custom properties plus every recipe class. Load it the way you'd load any CSS: inject a <style> tag at run-time (below), or emit it to a .css file at build time and <link> it (then you can drop refract from the client bundle). The provider does the run-time injection once and exposes the theme via context so components can read their classes.
import { createContext, useContext } from "react"; import type { Theme } from "@theme-registry/refract"; const ThemeContext = createContext<Theme | null>(null); export const useTheme = () => useContext(ThemeContext)!; export function ThemeProvider({ theme, children }) { return ( <ThemeContext.Provider value={theme}> {/* inject the stylesheet once, at the root */} <style dangerouslySetInnerHTML={{ __html: theme.css }} /> {children} </ThemeContext.Provider> ); }
<style> at run-time needs your style-src to permit it — either add a per-request nonce (<style nonce={nonce}>…</style> matching a style-src 'nonce-…' header) or, under a strict policy, prefer build-time delivery: emit theme.css to a file and <link> it (see Build-time) — a static stylesheet needs no style-src exception and drops refract from the client bundle entirely. The multi-tenant, per-request-theme case is exactly where the nonce matters; wire the theme's CSS through the same nonce your framework already issues.2 · Use a class
The quickest way to a class is theme.getClass(subsystem, group, variant) — it returns the ready-to-apply string (the fully composed list for a components variant), or undefined for an unknown address. refract has already built the composition; you're just reading it.
function SaveButton() { const theme = useTheme(); return <button className={theme.getClass("components", "buttons", "primary")}>Save</button>; } // → <button class="dt-colors-solid-brand … dt-components-buttons-primary">
Prefer the data form? theme.classes.components.buttons.primary is a { className, classList } pair — classList (the array) pairs well with a clsx-style helper for conditional classes. Recipes from the other subsystems are a plain class-name string.
theme.renderRecipe(subsystem, group, variant) — the finer-grained sibling of theme.css. See the full CSS adapter surface.theme.getClass / theme.classes / theme.css are the CSS adapter's theme surface — the class-first model this guide (and the Angular/Vue guides) use. With the styled-components adapter you don't apply class names at all: you import its emitted recipes and render styled components, so there's no getClass. Pick one adapter per app.3 · Switch themes at run-time
Keep the active theme in state and swap between the base and an override() child. Because the provider injects theme.css, changing the state value re-themes the whole tree — the child is a real, parent-untouched theme.
import { useState } from "react"; import { createTheme } from "@theme-registry/refract"; import { createCssAdapter } from "@theme-registry/refract-css"; const base = createTheme(raw, { adapter: createCssAdapter() }); const warm = base.override({ colors: { brand: { base: "#e8590c", text: "#ffffff" } } }); export function App() { const [theme, setTheme] = useState(base); return ( <ThemeProvider theme={theme}> <button onClick={() => setTheme(theme === base ? warm : base)}>Swap brand</button> <SaveButton /> </ThemeProvider> ); }
modes and toggle data-theme; it's a CSS-cascade flip with no re-render. Reserve theme-swapping for genuinely different themes (brands, presets).Inline styles?
Prefer classes. Inline style={{…}} can't express :hover/:disabled states, @media responsive, container queries, or @keyframes — which is most of what a recipe emits — so a class carries far more than an inline value can. The one fair use is a one-off dynamic value; even then:
// ✅ a var() reference still flips with the theme / dark-mode cascade <div style={{ color: "var(--dt-colors-brand)" }} /> // ⚠️ a resolved literal is frozen — it will NOT re-theme <div style={{ color: theme.resolveToken("colors.brand") }} />
Use resolveToken for logic (canvas, measurement, a computed value) — not for styling that should stay themable. The var(--…) name follows the naming grammar.
Using styled-components instead
If your app is on styled-components, use the styled-components adapter — it emits a literal theme object and tree-shakeable css recipes instead of a stylesheet. Wrap the app in one <ThemeProvider theme={theme}> and drop recipes into styled blocks (styled.button`${componentsButtonsPrimary}`); dark mode is an @media block inside each recipe, no provider swap.
Next.js / SSR
Because theme.css is just a string, server-rendering is straightforward: pick the theme per request, render its <style> into the initial HTML, and the page arrives already themed — no flash. This uses the App Router; nothing here is refract-specific beyond theme.css.
1 · Compile once, pick per request
Compile at module scope so themes are built once per server process, not per request — then select by tenant. override() children are cheap, so a brand map costs almost nothing.
import { createTheme } from "@theme-registry/refract"; import { createCssAdapter } from "@theme-registry/refract-css"; const base = createTheme(raw, { adapter: createCssAdapter() }); const brands = { acme: base.override({ colors: { brand: { base: "#e8590c", text: "#ffffff" } } }), globex: base.override({ colors: { brand: { base: "#2f9e44", text: "#ffffff" } } }), }; export const getTheme = (brand?: string) => brands[brand] ?? base;
2 · Inject in the root layout (no FOUC)
The root layout is a Server Component — derive the tenant from the request, render the theme's <style> in <head>. It's server-rendered into the first byte, so there's no unthemed flash.
import { headers, cookies } from "next/headers"; import { getTheme } from "@/lib/theme"; export default async function RootLayout({ children }) { const host = (await headers()).get("host") ?? ""; const theme = getTheme(host.split(".")[0]); // tenant from subdomain const mode = (await cookies()).get("theme")?.value; // "dark" | "light" | undefined return ( <html lang="en" data-theme={mode}> <head> <style dangerouslySetInnerHTML={{ __html: theme.css }} /> </head> <body>{children}</body> </html> ); }
headers() / cookies() makes the route dynamic (rendered per request) — expected for per-tenant theming. A single static theme needs neither; see the build-time option below.3 · Dark mode without a flash
Author modes and the OS preference is handled automatically by the emitted @media block. For a manual choice, set data-theme on <html> server-side from a cookie (as above) — the attribute is in the first byte, so the manual theme applies before paint, with no client flip flash.
Using the classes
In any component — server or client — read classes exactly as in the React guide: import the theme and call theme.getClass("components", "buttons", "primary"). Server Components can import the theme module directly.
theme.css to a file at build time and import "./theme.css" in the layout — refract leaves the client bundle completely. (Pages Router: the same idea, injecting in _document.)Angular
Same story as everywhere: a small injectable service holds the theme, keeps theme.css injected, and exposes getClass; templates bind [class]. Shown with standalone components and signals.
1 · A theme service
A root service owns the theme as a signal and mirrors theme.css into a <style> element via an effect — so setting a new theme re-themes the app.
import { Injectable, signal, effect, inject } from "@angular/core"; import { DOCUMENT } from "@angular/common"; import { createTheme, type Theme } from "@theme-registry/refract"; import { createCssAdapter } from "@theme-registry/refract-css"; import { raw } from "./theme.config"; @Injectable({ providedIn: "root" }) export class ThemeService { private doc = inject(DOCUMENT); private styleEl = this.doc.createElement("style"); readonly theme = signal<Theme>(createTheme(raw, { adapter: createCssAdapter() })); constructor() { this.doc.head.appendChild(this.styleEl); effect(() => (this.styleEl.textContent = this.theme().css)); // keep <style> in sync } setTheme(theme: Theme) { this.theme.set(theme); } getClass(sub: string, group: string, variant: string) { return this.theme().getClass(sub, group, variant); } }
2 · Use a class
Inject the service and bind [class] to getClass. The class list is composed by refract; the template just applies it.
import { Component, inject } from "@angular/core"; import { ThemeService } from "./theme.service"; @Component({ selector: "app-save-button", standalone: true, template: `<button [class]="theme.getClass('components', 'buttons', 'primary')">Save</button>`, }) export class SaveButtonComponent { protected theme = inject(ThemeService); }
3 · Switch themes
Swap in an override() child; the service's effect re-injects its CSS and every bound [class] updates.
const warm = base.override({ colors: { brand: { base: "#e8590c", text: "#ffffff" } } }); themeService.setTheme(warm); // re-injects theme.css via the effect
modes and toggle data-theme on document.documentElement (or via Renderer2) — a CSS-cascade flip, no re-inject. SSR (Angular Universal): inject theme.css into the server response the same way; or emit it to a file at build time and add it to the app's global styles.Vue
Same shape as everywhere: a shared theme ref, a watcher that keeps theme.css injected, and getClass in templates. Shown with the Composition API and <script setup>.
1 · A theme ref + provide it
Hold the theme in a ref, provide it to the tree, and mirror theme.css into a <style> with watchEffect — so replacing the ref re-themes the app.
import { ref, type Ref, type InjectionKey } from "vue"; import { createTheme, type Theme } from "@theme-registry/refract"; import { createCssAdapter } from "@theme-registry/refract-css"; import { raw } from "./theme.config"; export const ThemeKey: InjectionKey<Ref<Theme>> = Symbol("theme"); export const createThemeRef = () => ref<Theme>(createTheme(raw, { adapter: createCssAdapter() }));
<script setup lang="ts"> import { provide, watchEffect } from "vue"; import { ThemeKey, createThemeRef } from "./theme"; const theme = createThemeRef(); provide(ThemeKey, theme); watchEffect(() => { // keep <style> in sync let el = document.getElementById("refract"); if (!el) { el = document.createElement("style"); el.id = "refract"; document.head.appendChild(el); } el.textContent = theme.value.css; }); </script> <template><SaveButton /></template>
2 · Use a class
<script setup lang="ts"> import { inject } from "vue"; import { ThemeKey } from "./theme"; const theme = inject(ThemeKey)!; </script> <template> <button :class="theme.getClass('components', 'buttons', 'primary')">Save</button> </template>
3 · Switch themes
theme.value = base.override({ colors: { brand: { base: "#e8590c", text: "#ffffff" } } }); // watchEffect re-injects the new theme.css; every :class updates
modes and toggle data-theme on <html> — a cascade flip, no re-inject. Nuxt / SSR: inject theme.css server-side (a useHead style entry), or emit it at build time and add it to global CSS.Colors
Author each palette's base; refract synthesizes the full tonal scale — named steps or a numeric ramp — and every recipe references a step by path, never a frozen hex.
theme.resolveToken() at runtime.Overview
The colors subsystem owns the palette. Unlike the other property subsystems, it synthesizes tonal steps from each base colour — so you author one base and get a whole ramp (light, lighter, dark, darker, or numeric 100…900). Each colour is either a bare hex string or a full object; the keys are your own palette names (brand, accent, neutral, …).
Authoring a colour
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| base | ColorInput | Required | — | The colour — a hex string ("#4dabf7" or 3-digit "#4af"), an [r, g, b] tuple (0–255), or any CSS colour: oklch(), hsl()/hsla(), rgb()/rgba(), or a named keyword (rebeccapurple). Everything is normalized to a canonical rgb()/rgba(). Only var(--…), currentColor and transparent are rejected — they can't be tonally derived at build time. A bare value — success: "#40c057" — is shorthand for { base }. |
| text | ColorInput | Optional | — | The on-colour foreground (any ColorInput, same as base) → --dt-colors-<name>-text. |
| variants | Record<string, VariantInput> | Optional | — | Named variants — each a literal colour, a derivation spec ({ lighten | darken | alpha }, see below), or an extended { base, … }. Author-declared variants also seed the auto tonal steps. |
| steps | number[] | Optional | auto: light…darker | Numeric tonal steps (e.g. [100, …, 900]) — an absolute-lightness ladder: each label maps to a fixed OKLCH lightness via L = (1000 − label) / 10 (50→L95, 500→L50, 950→L5), so the same label reads at the same lightness across every palette. When omitted, the default named set (light / lighter / dark / darker) is auto-generated instead. |
| lightenBy | number | Optional | 10 | OKLCH lightness Δ (points, 0–100) per step along the lighter chain. Named set only — numeric steps use absolute lightness, not Δ. |
| darkenBy | number | Optional | 10 | OKLCH lightness Δ (points, 0–100) per step along the darker chain. Named set only. |
| harmony | scheme | { scheme: [names] } | Optional | — | Auto-generate related colours by hue rotation (complement / analogous / split-complement / triadic / tetradic) — see Harmony below. |
| responsive | ResponsiveEntry[] | Optional | — | @media overrides (see the Responsive concept page). |
Derivation — synthesized steps & variants
From one base, refract builds tonal steps in OKLCH — a perceptual space, so equal lightness moves look even and one lightness reads the same across hues. There are three ways to get variants:
- Auto tonal steps (relative) — with no
steps, the default setlight/lighter/dark/darkeris generated, each step compounding from the previous by an OKLCH lightness Δ. Authoredvariants.light/darkseed the chains, andlightenBy/darkenBy(default 10 points) set the Δ. - Numeric steps (absolute) —
steps: [100…900]is an absolute-lightness ladder: each label is placed atL = (1000 − label) / 10holding the base's hue and chroma. No pivot, no base alias — the exact authored colour stays at the unnumberedcolors.<name>token, and the same label lands at the same lightness in every palette. - Derivation-spec variants — author a named variant as a colour-fn derivation (below).
Every synthesized variant is stored as a derived ref — { ref, fn, arg } (a named step as lighten/darken with a Δ; a numeric rung as setL with its absolute lightness). Because it points at its source, theme.override() of a base re-derives the whole ramp for free. The lightness maths runs in OKLCH but round-trips through rgb at the boundary — which is why a colour must be tonally derivable: any hex, [r,g,b], oklch(), hsl(), rgb() or named keyword is parsed and works; only a var(--…) (unknown at build) can't. Each value is stored in one canonical form: rgb(r,g,b) when opaque, or rgba(r,g,b,a) once it carries alpha. Gamut: when a target lightness can't hold the base's full chroma in sRGB, refract keeps lightness and hue and eases chroma down — so a rung stays on-hue and on-lightness, only a touch less saturated.
steps label runs opposite to the lightness it maps to: L = (1000 − label) / 10, so a low label is light and a high label is dark — label 400 → L60, label 700 → L30. An adjust variant's l dial (below) is the raw OKLCH lightness instead (0–100, low = dark). So to place an adjust variant at the same lightness as the 400 rung you write adjust: { l: 60 } — not 400.The step ladder, visualized
The active preset's brand as a numeric ladder — each rung is a fixed OKLCH lightness (50 → L95 … 950 → L5). Flip the preset to re-synthesize it live.
Contrast & accessibility
Synthesis is perceptual (OKLCH), which makes contrast predictable — but predictable is not the same as guaranteed: refract emits your palette, it doesn't audit it. OKLCH lightness (L, 0–100) tracks perceived lightness closely, so a wide enough L gap between two colours is a reliable proxy for legible contrast. Ways to hit a target:
- Pick rungs with a large L gap. On the numeric ladder each label is an absolute lightness (
500→L50,900→L10), so pairing a light rung against a dark one with a generous L gap gives dependable contrast — e.g. text onscale.100reads well againstscale.800. - Author the
textpairing. Set the on-colour explicitly to a value you've checked against its base, rather than trusting a derived one — that pairing is the one that decides legibility. - Author the steps. Declare the specific step values (or
variants) you've verified, instead of relying on the default Δ.
alpha variant composites against whatever is behind it, so its effective contrast depends on the backdrop — check those pairings against the real background. Verify with a contrast checker (or in CI). A future declarative ladder curve could desaturate / hue-shift the extremes for even more control — it would re-resolve and replay like any derivation, never a baked-colour hook.Derivation-spec variants
Author a named variant as a derivation from another token by a colour fn. Give a modifiers chain of one or more single-key dials — { lighten } / { darken } / { alpha } / { adjust }, applied left-to-right — plus an optional ref (defaults to the own base; may name another variant/step or a cross-property token path):
| Key | Value | Effect |
|---|---|---|
| lighten | number (0–100) | Raise the source's OKLCH lightness by N points (hue, chroma & alpha preserved). |
| darken | number (0–100) | Lower the source's OKLCH lightness by N points (hue, chroma & alpha preserved). |
| alpha | number (0–100) | Set absolute opacity — alpha: 40 ⇒ 40% opaque; colour untouched. Serializes to rgba(…). Opacity is applied last and never enters the lightness maths. |
| adjust | { l?, c?, h? } | One-shot OKLCH placement (any dial optional): l = absolute lightness 0–100 (see the two-lightness note); c = chroma multiplier (1 keep, 0 grey); h = signed hue rotation in degrees. Numbers only. |
| ref | string | Optional source token; defaults to the colour's own base. May point at another variant / step. |
Reach for adjust when you want one-off exact placement: where lighten/darken nudge lightness relative to the source, adjust sets an absolute lightness and/or scales saturation and/or turns hue in a single derivation. It's still an ordinary derived variant ({ ref, modifiers: [{ adjust }] }), so it re-resolves on override() like any other variant.
Harmony — auto-generated related colours
Set harmony on a palette to synthesize related colours by rotating the base's hue around the perceptual wheel. Each generated variant holds the base's lightness and chroma — only the hue turns — so the set stays balanced. The string form uses each scheme's default names; the object form renames the members positionally.
primary: { base: "#4dabf7", harmony: "triadic" }
// → colors.primary.triadic1, colors.primary.triadic2
primary: { base: "#4dabf7", harmony: { triadic: ["mint", "coral"] } }
// → colors.primary.mint, colors.primary.coral
| Scheme | Members (hue rotation off the base) |
|---|---|
| complement | complement (180°) |
| analogous | analogous1 (−30°), analogous2 (30°) |
| split-complement | split1 (150°), split2 (210°) |
| triadic | triadic1 (120°), triadic2 (240°) |
| tetradic | tetradic1 (90°), complement (180°), tetradic2 (270°) |
Harmony, on the wheel
The active brand (ringed) plus a scheme's members, plotted by hue on the perceptual wheel — each dot is the real synthesized colour. Pick a scheme:
Emitted variables
| Output | CSS variable | Token path |
|---|---|---|
| base | --dt-colors-<name> | colors.<name> |
| base alias | --dt-colors-<name>-main | — |
| text | --dt-colors-<name>-text | colors.<name>.text |
| named step | --dt-colors-<name>-<step> | colors.<name>.<step> |
| numeric step | --dt-colors-<name>-<0…1000> | colors.<name>.<n> |
colors: {
// full object — literal variants seed the auto tonal steps
brand: { base: "#4c6ef5", text: "#fff", lightenBy: 10, darkenBy: 12,
variants: { light: "#91a7ff", dark: "#3b5bdb" } },
// [r,g,b] tuple base + derivation-spec variants
accent: { base: [230, 73, 128], text: "#fff",
variants: { hover: { modifiers: [{ darken: 12 }] }, ghost: { modifiers: [{ alpha: 12 }] } } },
// absolute-L ladder — 500 → L50, same label = same lightness across palettes
scale: { base: "#7c8db5", steps: [100, 200, …, 900] },
// named set with a custom Δ (OKLCH lightness points)
neutral: { base: "#868e96", lightenBy: 8, darkenBy: 10 },
// bare value → base only
success: "#40c057",
}
Recipes — solid / outline
A colour recipe composes palette refs into one class. Each value either names a palette reference — "brand" (base), "brand.text", "brand.dark" (a step) — or is a literal passed straight through (e.g. cursor: "pointer", backgroundColor: "transparent").
| Recipe prop | CSS declaration | Value |
|---|---|---|
| background | background | a colour ref or literal |
| backgroundColor | background-color | a colour ref or literal |
| color | color | a colour ref or literal |
| borderColor | border-color | a colour ref or literal |
| outlineColor | outline-color | a colour ref or literal |
| any other | passed through | a literal declaration (arbitrary CSS property) |
Recipes also take states (:hover, [disabled], …), a responsive list, and variant: / target: swaps — see the Concepts pages.
colors.recipes: {
solid: {
brand: {
background: "brand", color: "brand.text",
states: [
{ state: "hover", background: "brand.dark" },
{ state: "disabled", background: "brand.lighter", color: "brand.dark" },
],
},
},
outline: {
brand: { backgroundColor: "transparent", color: "brand",
borderColor: "brand", cursor: "pointer" },
},
}
:root { --dt-colors-brand: #4c6ef5; --dt-colors-brand-text: #ffffff; --dt-colors-brand-light: #91a7ff; /* authored seed */ --dt-colors-brand-lighter: #bccbff; /* synth: lighten(light, 10) */ --dt-colors-brand-dark: #3b5bdb; --dt-colors-brand-darker: #2033b2; /* synth: darken(dark, 12) */ --dt-colors-accent: #e64980; /* [230,73,128] tuple → hex */ --dt-colors-accent-ghost: rgba(230, 73, 128, 0.12); /* alpha: 12 → rgba() */ } .dt-colors-solid-brand { background: var(--dt-colors-brand); color: var(--dt-colors-brand-text); } .dt-colors-solid-brand:hover { background: var(--dt-colors-brand-dark); }
Contrast audit
Colours are the flagship, so refract ships an opt-in contrast audit. It scores every palette base↔text pairing and every recipe foreground↔background pairing (across all subsystems, including state overrides like :hover) against WCAG 2 contrast ratio, alongside an advisory APCA Lc reading. It reports — it never rewrites a colour. A side that isn't a derivable colour (transparent, a var(), a keyword) is skipped, not failed.
# CLI — reports by default (exit 0); --strict fails the run (exit 1) $ refract audit ✓ colors.ink — 21:1 AAA · APCA Lc -107.9 ✗ colors.brand — 4.32:1 AA-large · APCA Lc -75 1/2 pass, 1 fail, 0 skipped // …or programmatically — the same scores as structured data import { audit } from "@theme-registry/refract"; const { pairings, summary, ok } = audit(theme, { minWcag: "AA" });
WCAG levels: AAA ≥ 7:1, AA ≥ 4.5:1, and AA-large ≥ 3:1 (passes only for large text). Options: --strict (throw / non-zero exit on any failure), --min-wcag <AA|AAA|AA-large> (the pass bar), and --large (relax to large-text thresholds). APCA is advisory — a perceptual model still in draft — so it is reported, never gated.
Typography
A modular type scale, families and weights become tokens; heading and text recipes compose them into ready-to-use classes.
base × ratio. The recipe rows carry real emitted classes (dt-typography-heading-h1, dt-typography-body-base) from theme.css.Overview
The typography subsystem owns the text tokens — font family, weight, line-height, letter-spacing, and the extra text properties (style / transform / decoration / align) — plus the fontSize modular scale, which (like colours) is synthesized: author a base and a ratio and refract computes xs…4xl. Recipes (heading, body, button, …) compose these into named text styles.
Properties
Each is a normal property (base + named variants). Note the emitted variable uses the CSS-property name directly — there's no typography segment — while the token path is typography.<prop>.
| Property | Value type | Emitted variable | Description |
|---|---|---|---|
| fontFamily | string | --dt-typography-fontfamily-<v> | Font stacks (e.g. base, display, mono). |
| fontWeight | number | --dt-typography-fontweight-<v> | Weights (400, 600, …). |
| lineHeight | number | --dt-typography-lineheight-<v> | Unitless line-heights. |
| letterSpacing | string | --dt-typography-letterspacing-<v> | Tracking (em / px strings). |
| fontStyle | string | --dt-typography-fontstyle-<v> | normal / italic. |
| textTransform | string | --dt-typography-texttransform-<v> | none / uppercase / … |
| textDecoration | string | --dt-typography-textdecoration-<v> | none / underline / … |
| textAlign | string | --dt-typography-textalign-<v> | left / center / … |
| fontSize | number | --dt-typography-fontsize-<v> | The modular scale — see below. |
The modular scale — fontSize
Author a base and a ratio; refract generates the steps as base × ratiostep, rounded to precision. Steps: xs (−2) · sm (−1) · md/base (0) · lg (+1) · xl (+2) · 2xl (+3) · 3xl (+4) · 4xl (+5). An author-declared step seeds/overrides the computed value.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| base | number | Required | — | The base size — the md / base step. |
| ratio | RatioKey | Optional | — | The scale ratio (see below). Without it, no steps are synthesized. |
| precision | number | Optional | 4 | Decimal rounding for computed sizes. |
| unit | "px" | "rem" | Optional | px | Authoring hint for the scale ratio math; the emitted unit is resolved by the units config (px by default). |
| baseFontSize | number | Optional | 16 | Divisor for rem conversion. |
| algorithm | (base, key, step, prev) => number | Optional | — | Custom step function — overrides the ratio math. |
| variants | Record<key, number> | Optional | — | Author-declared step seeds (e.g. "4xl": 56 overrides the computed value). |
Ratios: minor-second 1.067 · major-second 1.125 · minor-third 1.2 · major-third 1.25 · perfect-fourth 1.333 · augmented-fourth 1.414 · perfect-fifth 1.5 · golden 1.618.
typography: {
fontFamily: { base: "system-ui, sans-serif",
variants: { display: "'Iowan Old Style', serif", mono: "'JetBrains Mono', monospace" } },
fontWeight: { base: 400, variants: { medium: 500, semibold: 600, bold: 700 } },
lineHeight: { base: 1.55, variants: { tight: 1.15, snug: 1.35 } },
letterSpacing: { base: "0", variants: { tight: "-0.02em", wide: "0.06em" } },
textTransform: { base: "none", variants: { upper: "uppercase" } },
// the modular scale
fontSize: { base: 16, ratio: "major-third", precision: 2, variants: { "4xl": 56 } },
}
Recipes — heading / body / button / …
A typography recipe composes token variants into one class. Each value names a variant (fontSize: "3xl", fontWeight: "bold", fontFamily: "display"; "base" for the base value).
| Recipe prop | CSS declaration | References |
|---|---|---|
| fontFamily | font-family | fontFamily |
| fontSize | font-size | fontSize |
| fontWeight | font-weight | fontWeight |
| lineHeight | line-height | lineHeight |
| letterSpacing | letter-spacing | letterSpacing |
| fontStyle | font-style | fontStyle |
| textTransform | text-transform | textTransform |
| textDecoration | text-decoration | textDecoration |
| textAlign | text-align | textAlign |
Recipes also take states (e.g. a link hover underline) and a responsive list (e.g. a heading that bumps a size step on wide viewports) — see the Concepts pages.
typography.recipes: {
heading: {
h1: { fontFamily: "display", fontSize: "3xl", fontWeight: "bold",
lineHeight: "tight", letterSpacing: "tight",
responsive: [{ breakpoint: "lg", query: "min", fontSize: "4xl" }] },
},
link: { default: { textDecoration: "base", states: [{ state: "hover", textDecoration: "underline" }] } },
}
:root { --dt-typography-fontfamily-display: 'Iowan Old Style', serif; --dt-typography-fontsize: 16px; --dt-typography-fontsize-3xl: 39.06px; /* 16 × 1.25⁴ */ --dt-typography-fontsize-4xl: 56px; /* authored seed */ --dt-typography-fontweight-bold: 700; } .dt-typography-heading-h1 { font-family: var(--dt-typography-fontfamily-display); font-size: var(--dt-typography-fontsize-3xl); font-weight: var(--dt-typography-fontweight-bold); line-height: var(--dt-typography-lineheight-tight); letter-spacing: var(--dt-typography-letterspacing-tight); } @media (min-width: 1024px) { .dt-typography-heading-h1 { font-size: var(--dt-typography-fontsize-4xl); } }
Effects
Shadow, blur, opacity, transitions and z-index as format-neutral tokens — multi-layer values with colours referenced by path, live on hover.
dt-effects-surface-card / -focusable — hovering fires the real transition + shadow tokens. Flip the preset: the shadow depth & motion recompute from that theme.Overview
The effects subsystem owns the visual-treatment tokens — shadow, blur, opacity, z-index and transitions. Each is a plain property (a base value plus named variants) emitted as a :root custom property, and each can be referenced by a surface recipe that composes several into one class. It's a “regular” subsystem — values pass straight through (no palette-style synthesis like colors).
radius, borderWidth, outline — lives in the dedicated borders subsystem, not effects.Properties
Five token families. shadow / transitions are structured object-leaf properties — authored as objects/arrays of leaves (the only accepted string is the "none" keyword); opacity / zIndex are unitless numbers; blur is a number → px (or a string).
| Property | Value type | Emitted variable | Description |
|---|---|---|---|
| shadow | ShadowValue | --dt-effects-shadow-<v> | Structured box-shadow layers (or "none") — see below. |
| transitions | TransitionValue | --dt-effects-transitions-<v> | Structured transition parts (or "none") — see below. |
| opacity | number | --dt-effects-opacity-<v> | Unitless 0–1. |
| zIndex | number | --dt-effects-zindex-<v> | Unitless stacking index. |
| blur | number | string | --dt-effects-blur-<v> | Blur radius; numbers → px. Consumed by the recipe blur prop (→ filter). |
The base value is emitted at --dt-effects-<prop> and resolvable at token path effects.<prop>; each variant at …-<variant> / effects.<prop>.<variant>.
Structured shadow & transitions
shadow and transitions are authored as leaves, never raw CSS strings. A single flat leaf sits at the property top level and is the base; variants / responsive / modes hold full leaf values (a multi-layer shadow / multi-part transition is an array of leaves in a variant or mode). There is no base key; a property with only variants has an implicit "none" base. The only accepted string is the keyword "none" (no shadow / no transition) — any other string throws. A shadow stays translucent by referencing a translucent colour — an alpha colour variant (see colours) — not a shadow-level opacity field.
| Shadow layer (ShadowLayerInput) | ||
|---|---|---|
| Field | Type | Description |
| offsetX / offsetY | number | string | Offsets. A bare number is a deferred length (px by default, per the units config); a string is a pinned length ("1px", "0.5rem"). Default 0. |
| blur | number | string | Blur radius — deferred length, or a pinned string. |
| spread | number | string | Spread radius — deferred length, or a pinned string. |
| color | string | A colors.* token ref → var(--dt-colors-…) (never a raw colour). Point it at an alpha colour variant for a translucent shadow. |
| inset | boolean | Renders an inset shadow. |
| Transition part (TransitionPartInput) | ||
|---|---|---|
| Field | Type | Description |
| property | string | The animated property (e.g. "all", "transform"). |
| duration | number | Duration (ms). |
| timingFunction | string | Easing keyword / function. |
| delay | number | Delay (ms). |
colors: {
// a translucent shadow ink: an `alpha` colour variant (10% opaque)
ink: { base: "#0e1117", variants: { soft: { modifiers: [{ alpha: 10 }] } } },
},
effects: {
shadow: {
// a flat leaf at the top level IS the base; translucency comes from the colour it refs
offsetY: 1, blur: 2, color: "colors.ink.soft",
variants: {
none: "none", // the only accepted string
md: { offsetY: 6, blur: 16, color: "colors.ink.soft" },
// an array in a variant = a multi-layer shadow
lg: [{ offsetY: 10, blur: 20, color: "colors.ink.soft" }, { offsetY: 2, blur: 4, color: "colors.ink.soft" }],
},
},
transitions: {
property: "all", duration: 160, timingFunction: "ease-out",
variants: { fast: { property: "all", duration: 90, timingFunction: "ease" } },
},
opacity: { base: 1, variants: { muted: 0.6, disabled: 0.4 } },
zIndex: { base: 1, variants: { dropdown: 1000, modal: 1300 } },
blur: { base: 0, variants: { sm: 4, lg: 16 } },
}
:root { --dt-colors-ink-soft: rgba(14, 17, 23, 0.1); --dt-effects-shadow: 0px 1px 2px var(--dt-colors-ink-soft); --dt-effects-shadow-lg: 0px 10px 20px var(--dt-colors-ink-soft), 0px 2px 4px var(--dt-colors-ink-soft); --dt-effects-transitions: all 160ms ease-out; }
Recipes — surface
A surface recipe composes effect variants into one class. Each recipe prop names a variant of the matching property — but the recipe key differs from the token key, so the interpreter maps it:
| Recipe prop | References property | CSS declaration | Notes |
|---|---|---|---|
| boxShadow | shadow | box-shadow | — |
| transition | transitions | transition | Recipe key is singular. |
| opacity | opacity | opacity | — |
| zIndex | zIndex | z-index | — |
| blur | blur | filter | Compound → filter: blur(var(--…)). The wrap lives in the adapter; no embedded var() in the Model. |
Recipe declarations resolve to token-path refs — boxShadow: "lg" → effects.shadow.lg → var(--dt-effects-shadow-lg). Recipes also take states (:hover, [disabled], …) and a responsive list (@media overrides) — see the Concepts pages.
effects.recipes: {
surface: {
card: {
boxShadow: "md", transition: "base",
states: [{ state: "hover", boxShadow: "lg" }],
responsive: [{ breakpoint: "lg", query: "min", boxShadow: "lg" }],
},
focusable: {
boxShadow: "sm", opacity: "muted", zIndex: "modal", blur: "sm",
},
},
}
.dt-effects-surface-card { box-shadow: var(--dt-effects-shadow-md); transition: var(--dt-effects-transitions); } .dt-effects-surface-card:hover { box-shadow: var(--dt-effects-shadow-lg); } @media (min-width: 1024px) { .dt-effects-surface-card { box-shadow: var(--dt-effects-shadow-lg); } } .dt-effects-surface-focusable { box-shadow: var(--dt-effects-shadow-sm); opacity: var(--dt-effects-opacity-muted); z-index: var(--dt-effects-zindex-modal); filter: blur(var(--dt-effects-blur-sm)); }
Borders
One stroke vocabulary — width, style, offset and radius — plus edge recipes that compose them, per side.
edge recipes are real emitted classes (dt-borders-edge-*); the components on the Components page compose borders: "edge.button" / "edge.card" for their corners.Overview
The borders subsystem is the dedicated stroke subsystem. Border and outline share one geometry vocabulary (width / style / offset / radius); a recipe diverges only at the render target via the as verb. Colour is never a borders token — a borders recipe carries a value-level colors.* ref instead.
Properties
Four geometry token families — a base value plus named variants, emitted as :root variables. width / offset are numbers → px; radius is a number → px (or a string like "9999px"); style is a raw keyword.
| Property | Value type | Emitted variable | Description |
|---|---|---|---|
| width | number | --dt-borders-width-<v> | border / outline width (px). |
| style | string | --dt-borders-style-<v> | solid / dashed / … (plus auto for outlines). |
| offset | number | --dt-borders-offset-<v> | outline-offset (px) — only meaningful under as: "outline". |
| radius | number | string | --dt-borders-radius-<v> | border-radius (px); "9999px"-style strings allowed. |
Base at token path borders.<prop> / --dt-borders-<prop>; each variant at borders.<prop>.<variant> / …-<variant>.
borders: {
width: { base: 1, variants: { thick: 2, hair: 0.5 } },
style: { base: "solid", variants: { dashed: "dashed" } },
offset: { base: 2, variants: { lg: 4 } },
radius: { base: 8, variants: { none: 0, lg: 16, pill: "9999px" } },
}
Recipes — as / side + geometry aspects
A borders recipe composes the geometry into one class. The aspects (width / style / offset / radius) each name a variant of the matching property; as and side are modifiers that route each aspect to its longhand; color is a value-level colors.* token ref.
| Prop | Type | Description |
|---|---|---|
| as | "border" | "outline" | Render target (default "border"). Routes every aspect to its longhand. |
| side | "top" | "right" | "bottom" | "left" | Per-side modifier (border only) → border-<side>-{width,style,color}. |
| width | string | Names a width variant. |
| style | string | Names a style variant. |
| offset | string | Names an offset variant (outline-only). |
| radius | string | Names a radius variant (always border-radius). |
| color | string | A colors.* token path ("colors.primary") — never a borders token → border/outline-color. |
The CSS longhand is computed from (as, side, aspect):
radius→ alwaysborder-radius;offset→ alwaysoutline-offset.as: "outline"→outline-<aspect>(width / style / color).- otherwise
border-<aspect>, orborder-<side>-<aspect>when asideis set.
borders.recipes: {
edge: {
card: { width: "base", style: "base", radius: "lg", color: "colors.primary" },
focus: { as: "outline", width: "thick", style: "base", offset: "lg", color: "colors.primary" },
divider: { side: "bottom", width: "hair", style: "base", color: "colors.primary" },
},
}
/* as: "border" (default) — full border */ .dt-borders-edge-card { border-width: var(--dt-borders-width); border-style: var(--dt-borders-style); border-radius: var(--dt-borders-radius-lg); border-color: var(--dt-colors-primary); /* a colors.* ref */ } /* as: "outline" — offset only applies here */ .dt-borders-edge-focus { outline-width: var(--dt-borders-width-thick); outline-style: var(--dt-borders-style); outline-offset: var(--dt-borders-offset-lg); outline-color: var(--dt-colors-primary); } /* side: "bottom" — per-side longhands */ .dt-borders-edge-divider { border-bottom-width: var(--dt-borders-width-hair); border-bottom-style: var(--dt-borders-style); border-bottom-color: var(--dt-colors-primary); }
dt-borders-<group>-<variant> (read from theme.classes.borders.<group>.<variant>); a component recipe composes it like any other subsystem — borders: "edge.card".Animation
Durations, easings and keyframes as tokens; motion recipes bind them into a real animation shorthand.
animation.recipes.motion.enter lowers to one class whose animation: shorthand joins a duration + easing token with a named @keyframes. Both — the keyframes and the class — are real emitted output.Overview
The animation subsystem owns three things: motion tokens (duration / easing / delay), keyframes (a distinct Model primitive — named, ordered step lists), and animation-shorthand recipes that name a keyframe plus motion tokens and lower to one class carrying an animation: shorthand. Transitions live in the effects subsystem, not here.
Motion tokens
Regular properties (base + named variants), emitted as :root variables. Durations and delays are numbers (ms); easings are strings.
| Property | Value type | Emitted variable | Description |
|---|---|---|---|
| duration | number (ms) | --dt-animation-duration-<v> | Animation durations. |
| easing | string | --dt-animation-easing-<v> | Timing functions (cubic-bezier(…), steps(…), …). |
| delay | number (ms) | --dt-animation-delay-<v> | Start delays. |
Keyframes
Keyframes are a first-class Model primitive — neither a token nor a rule-set. Author a name → a map of stops → declarations. A stop is from / to / a percentage ("50%") / a grouped selector ("0%, 100%"); authoring order is preserved. A declaration value is a literal or a token ref ({ ref: "colors.surface" }) resolved late — so a keyframe can animate a themed value. Each emits an @keyframes <name> at-rule.
animation.keyframes: {
fadeUp: { from: { opacity: 0, transform: "translateY(14px)" },
to: { opacity: 1, transform: "translateY(0)" } },
pulse: { "0%,100%": { opacity: 1 }, "50%": { opacity: 0.5 } },
}
Recipes — animation shorthand
An animation recipe names a keyframe plus motion tokens; the adapter composes the animation-* longhands into a single animation: shorthand. keyframes names a keyframe; duration / easing / delay name a token variant; the rest are literal animation-* sub-properties.
| Recipe prop | Contributes | Value |
|---|---|---|
| keyframes | animation-name | a keyframe name |
| duration | animation-duration | a duration variant |
| easing | animation-timing-function | an easing variant |
| delay | animation-delay | a delay variant |
| iterationCount | animation-iteration-count | literal ("infinite", 3) |
| direction | animation-direction | literal ("alternate", …) |
| fillMode | animation-fill-mode | literal ("both", "forwards", …) |
| playState | animation-play-state | literal ("paused", …) |
Recipes also take states and a responsive list (with variant: swaps) — see the Concepts pages.
animation.recipes: {
motion: {
enter: { keyframes: "fadeUp", duration: "base", easing: "out", fillMode: "both" },
beat: { keyframes: "pulse", duration: "slow", easing: "base", iterationCount: "infinite" },
},
}
@keyframes fadeUp { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: translateY(0); } } .dt-animation-motion-enter { animation: var(--dt-animation-duration) var(--dt-animation-easing-out) both fadeUp; } .dt-animation-motion-beat { animation: var(--dt-animation-duration-slow) var(--dt-animation-easing) infinite pulse; }
Layout
The structural layer — a spacing scale plus generators for columns, grids, stacks and resizable query containers.
dt-layout-grids-grid-cards class; the container is dt-layout-container-container. Drag its right edge to watch the layout respond.Overview
The layout subsystem is the only closed subsystem — its keys are fixed. It has three kinds of member: regular property tokens (spacing, gutters, aspectRatio, sizes) that emit :root variables; four structural generators (columns, grids, stacks, container) that emit utility classes; and recipes that compose spacing and the sizes scale into classes.
Properties
Regular tokens — a base plus named variants, emitted as :root variables. The structural generators and recipes reference these by variant name.
| Property | Value type | Emitted variable | Description |
|---|---|---|---|
| spacing | number | string | --dt-layout-spacing-<v> | The spacing scale (numbers → px). Referenced by padding/margin/gap and gutters. |
| gutters | number | string | --dt-layout-gutters-<v> | Gutter widths for columns / container. |
| aspectRatio | string | --dt-layout-aspectratio-<v> | Aspect ratios (e.g. "16/9"). |
| sizes | number | string | --dt-layout-sizes-<v> | The sizing scale (§22) — one length scale for width / height / min / max, consumed by a recipe's sizing verbs and by a container's maxWidth. |
spacing: { base: 8, variants: { none: 0, sm: 8, md: 12, lg: 18, xl: 28 } },
gutters: { base: 16, variants: { compact: 10, relaxed: 32 } },
aspectRatio: { base: "auto", variants: { square: "1", video: "16/9", wide: "21/9" } },
sizes: { base: "40rem", variants: { sm: "24rem", md: "32rem", lg: "48rem", prose: "65ch" } },
Synthesized scales
spacing, gutters and sizes can generate their variant ramp from a base and a curve — the same idea as colours' tonal steps and typography's modular scale, so you declare the shape once instead of hand-listing every rung. It's opt-in and additive: with no curve key, output is byte-identical to a hand-listed scale.
Declare exactly one curve. Geometric — a ratio; steps is an ordered name array whose index is the exponent (base × ratioindex). Linear — a step; steps is a name → multiplier map (step × multiplier). Declaring both ratio and step is an error. steps is optional — the default ladder is xs sm md lg xl 2xl 3xl 4xl (geometric: index = exponent; linear: multiplier = index + 1). Precision is fixed at 4 decimals.
| Curve | Key | steps shape | Formula |
|---|---|---|---|
| geometric | ratio | ordered name array (index = exponent) | round(base × ratioindex, 4) |
| linear | step | name → multiplier map | round(step × multiplier, 4) |
spacing: { base: 8, ratio: 1.5, steps: ["xs", "sm", "md", "lg", "xl"] }
// → xs 8 · sm 12 · md 18 · lg 27 · xl 40.5 (base × 1.5^index; 40.5 kept, not rounded to int)
spacing: { base: 4, step: 4, steps: { xs: 1, sm: 2, md: 3, lg: 4, xl: 6 } }
// → xs 4 · sm 8 · md 12 · lg 16 · xl 24 (step × multiplier — a 4pt grid)
--dt-layout-spacing-xs: 8px; --dt-layout-spacing-md: 18px; --dt-layout-spacing-xl: 40.5px; --dt-layout-spacing-none: 0px; /* the forced none stays on spacing / gutters */
Each rung is stored as a derived Ref (fn: "scaleStep") — like colours' tonal steps, not a frozen literal — so override() of the base re-synthesizes the whole ramp (override base: 16 on the geometric example and md becomes 36). A hand-authored variants entry always wins over the generated rung of the same name.
sizes coexistence. A synthesized t-shirt ramp lives alongside hand-authored semantic caps and pinned values: sizes: { base: 320, ratio: 1.5, steps: ["sm","md","lg","xl"], variants: { prose: 640, wide: 1200, full: "100%" } } gives sm 320 · md 480 · lg 720 · xl 1080 plus the literal prose 640 / wide 1200 / full 100%. A pinned % has no magnitude to multiply, so it can only ever be an authored variant — never synthesized. sizes gets no forced none. Rule of thumb: a linear grid for spacing, a geometric ramp for sizes.
Responsive ramps. A responsive entry carrying a ratio or step (plus an optional base) regenerates the whole named scale at that breakpoint — it expands into one target override per step over the existing responsive channel (no new Model shape). responsive: [{ breakpoint: "sm", query: "max", ratio: 1 }] flattens every step to the base on mobile (base × 1index); responsive: [{ breakpoint: "lg", base: 6, step: 6 }] emits a fuller ramp above lg (6 · 12 · 18 for xs:1 / sm:2 / md:3).
Structural generators
These don't emit tokens — they generate utility classes from a compact config (consuming the spacing / gutter variants above), accessible via theme.classes.layout. There are four, each documented below.
| Generator | Authored as | Emits |
|---|---|---|
| columns | { size, gutter?, inset? } | .dt-layout-columns-col-<bp>-<1…size> · .dt-layout-columns-offset-<bp>-<n> |
| grids | { <name>: GridDefinition } | .dt-layout-grids-grid-<name> (display: grid) |
| stacks | { <name>: StackDefinition } | .dt-layout-stacks-stack-<name> (flex / inline-flex) |
| container | mode string | config + variants | .dt-layout-container-container · .dt-layout-container-container-<variant> |
columns — the grid system
A responsive column system. size sets the column count; the generator emits a span class and an offset class for every count 1…size, at every breakpoint (the sm+ classes are wrapped in @media (min-width: …)). Children carry .dt-layout-columns-col-<bp>-N inside a CSS grid you establish (or a grid recipe). gutter / inset name gutter / spacing variants, exposed as config vars.
| Field | Type | Required | Description |
|---|---|---|---|
| size | number | Required | The column count (e.g. 12). |
| gutter | string | Optional | Names a gutters variant → --dt-layout-columns-gutter. |
| inset | string | Optional | Names a spacing variant → --dt-layout-columns-inset. |
layout.columns: { size: 12, gutter: "compact", inset: "sm" }
.dt-layout-columns-col-xs-1 { grid-column-end: span 1; } .dt-layout-columns-col-xs-6 { grid-column-end: span 6; } /* … through span 12 */ .dt-layout-columns-offset-xs-2 { grid-column-start: 3; } @media (min-width: 576px) { .dt-layout-columns-col-sm-6 { grid-column-end: span 6; } /* the sm breakpoint set */ }
grids — named CSS grid templates
Each named grid emits a .dt-layout-grids-grid-<name> class of display: grid plus the template you author. gap names a spacing variant; a responsive list re-templates at breakpoints.
| Field | Type | Description |
|---|---|---|
| templateColumns | string | grid-template-columns. |
| templateRows | string | grid-template-rows. |
| autoColumns / autoRows | string | grid-auto-columns / grid-auto-rows. |
| justifyItems / alignItems | string | Item alignment on each axis. |
| justifyContent / alignContent | string | Track alignment on each axis. |
| gap | string | Names a spacing variant. |
| responsive | ResponsiveEntry[] | Per-breakpoint overrides of any field above → @media. |
layout.grids: {
cards: { templateColumns: "repeat(auto-fill, minmax(140px, 1fr))", gap: "lg", alignItems: "start" },
dashboard: { templateColumns: "repeat(4, minmax(0,1fr))", gap: "md",
responsive: [{ breakpoint: "lg", query: "max", templateColumns: "repeat(2, minmax(0,1fr))" }] },
}
.dt-layout-grids-grid-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); align-items: start; gap: var(--dt-layout-spacing-lg); } @media (max-width: 1023.98px) { .dt-layout-grids-grid-dashboard { grid-template-columns: repeat(2, minmax(0,1fr)); } }
stacks — flexbox stacks
Each named stack emits a .dt-layout-stacks-stack-<name> flex container. inline: true makes it inline-flex; gap names a spacing variant; a responsive list overrides direction / align / etc. at breakpoints.
| Field | Type | Description |
|---|---|---|
| direction | "row" | "column" | flex-direction. |
| align | string | align-items. |
| justify | string | justify-content. |
| wrap | string | flex-wrap. |
| inline | boolean | true → inline-flex. |
| gap | string | Names a spacing variant. |
| responsive | ResponsiveEntry[] | Per-breakpoint overrides → @media. |
layout.stacks: {
toolbar: { direction: "row", gap: "sm", align: "center", justify: "space-between",
responsive: [{ breakpoint: "sm", query: "max", direction: "column", align: "stretch" }] },
tags: { direction: "row", inline: true, wrap: "wrap", gap: "xs" },
}
.dt-layout-stacks-stack-toolbar { display: flex; flex-direction: row; align-items: center; justify-content: space-between; gap: var(--dt-layout-spacing-sm); } @media (max-width: 767.98px) { .dt-layout-stacks-stack-toolbar { flex-direction: column; align-items: stretch; } } .dt-layout-stacks-stack-tags { display: inline-flex; flex-direction: row; flex-wrap: wrap; gap: var(--dt-layout-spacing-xs); }
container — page containers
A centering wrapper. The mode (base) is "fixed" (centered, capped at maxWidth), "fluid" (full-width up to maxWidth), or a raw width string ("720px"). Named variants each emit a .dt-layout-container-container-<variant>; a responsive list overrides fields at breakpoints. inset / gutter name spacing / gutter variants → per-container config vars.
| Field | Type | Description |
|---|---|---|
| base | string | The mode — "fixed" / "fluid" / a width string. |
| inset | string | Horizontal padding — names a spacing variant. |
| gutter | string | Inner gap — names a gutters variant. |
| maxWidth | string | number | Cap width — names a sizes variant (§22), or a raw length (a number → the media unit). |
| direction / align / justify | string | Flow of the container's own children. |
| variants | Record<string, ContainerVariant> | Named variants → .dt-layout-container-container-<variant>. |
| responsive | ResponsiveEntry[] | Per-breakpoint overrides (with target) → @media. |
layout.container: {
base: "fixed", inset: "lg", gutter: "base", maxWidth: "lg",
variants: {
narrow: { base: "fixed", maxWidth: "md" },
fluid: { base: "fluid", maxWidth: 1600, inset: "xl" },
prose: { base: "720px" },
},
}
.dt-layout-container-container { box-sizing: border-box; width: 100%; margin-left: auto; margin-right: auto; padding-left: var(--dt-layout-container-inset); padding-right: var(--dt-layout-container-inset); gap: var(--dt-layout-container-gutter); } .dt-layout-container-container-fluid { /* … */ max-width: 1600px; } .dt-layout-container-container-prose { /* … */ max-width: 720px; }
Recipes — padding / section
A layout recipe names a spacing variant per prop; one prop can fan out to several declarations (paddingY → padding-top + padding-bottom). The sizing verbs (§22) name a sizes variant instead — one declaration each, no fan-out. background passes through as a literal.
| Recipe prop | CSS declarations | Value |
|---|---|---|
| paddingY | padding-top · padding-bottom | a spacing variant |
| paddingX | padding-left · padding-right | a spacing variant |
| marginY | margin-top · margin-bottom | a spacing variant |
| marginX | margin-left · margin-right | a spacing variant |
| gap | gap | a spacing variant |
| width · minWidth · maxWidth | width · min-width · max-width | a sizes variant |
| height · minHeight · maxHeight | height · min-height · max-height | a sizes variant |
| background | background | a literal |
// panel: { card: { maxWidth: "md", width: "lg" } } → .dt-layout-panel-card { max-width: var(--dt-layout-sizes-md); width: var(--dt-layout-sizes-lg); }
sizes). Dimensional CSS with no scale — display, position, overflow — stays in a component's own css delta, not here.layout.recipes: {
padding: { card: { paddingY: "lg", paddingX: "lg" }, button: { paddingY: "sm", paddingX: "lg" } },
section: { hero: { paddingY: "3xl", paddingX: "xl", marginY: "xl", gap: "lg" } },
}
:root { --dt-layout-spacing-lg: 18px; --dt-layout-gutters-compact: 10px; } .dt-layout-columns-col-xs-6 { grid-column-end: span 6; } .dt-layout-grids-grid-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); align-items: start; gap: var(--dt-layout-spacing-lg); } .dt-layout-stacks-stack-toolbar { display: flex; flex-direction: row; align-items: center; justify-content: space-between; gap: var(--dt-layout-spacing-sm); } .dt-layout-container-container { box-sizing: border-box; width: 100%; margin-left: auto; margin-right: auto; padding-left: var(--dt-layout-container-inset); /* … */ } .dt-layout-padding-card { padding-top: var(--dt-layout-spacing-lg); padding-bottom: var(--dt-layout-spacing-lg); /* … */ }
Components
The payoff: a component recipe is pure composition — it references a colours + typography + layout + effects + borders recipe and adds its own delta.
css delta. The chip row under each control is its real classList (own delta outlined).Overview
The components subsystem is closed and recipes-only — it owns no primitive properties and emits no tokens; the property pipeline never runs for it. A component recipe is pure composition: it references other subsystems' recipes and adds its own css delta. This is where every other subsystem lands together.
How composition works
A component variant names a recipe from one or more subsystems by that subsystem's key, and optionally adds its own css delta. refract resolves it to a class list: the referenced recipe classes (which carry their own :hover etc. on their shared class) followed by the component's own delta class (dt-components-<group>-<variant>). Read it from theme.classes.components.<group>.<variant>.
css delta is literal-first — it's raw CSS. A bare string or number is a literal (cursor: "pointer", gap: 8); a token reference uses the ref() helper (color: ref("colors.brand.text") → var(--dt-colors-brand-text)) or the JSON-safe { ref: "…" } object. This differs from the composition fields above (colors: "solid.brand"), where a bare string is always a reference — those compose tokens, the css block writes CSS. A ref() at an unknown token is a build error. Because a literal css value passes through verbatim, sanitize it before serving output built from an untrusted theme — see the FAQ.| Prop | Type | Description |
|---|---|---|
| colors | string | A colors recipe ref — e.g. "solid.brand". |
| typography | string | A typography recipe ref — e.g. "button.base". |
| layout | string | A layout recipe ref — e.g. "padding.button". |
| effects | string | An effects recipe ref — e.g. "surface.card". |
| <subsystem> | string | Any subsystem key → its <group>.<variant> recipe (e.g. animation: "motion.enter"). |
| css | Record<string, CssDeltaValue> | The own CSS delta rendered on the dt-components-… class. Literal-first (see the note above): a bare string / number is a raw literal, and ref("…") / { ref: "…" } is a token reference → var(--…). |
| states | StateEntry[] | Own-delta state overrides — a list of { state, target?, … }; each carries a css block (e.g. { state: "hover", css: {…} }). Optional target scopes onto a variant sibling. |
| responsive | ResponsiveEntry[] | Own-delta @media overrides; each carries a css block. |
The resolved class
theme.classes.components.<group>.<variant> returns a ResolvedComponentClass:
| Field | Type | Description |
|---|---|---|
| className | string | The space-joined class list — referenced recipe classes, then the own delta class. Apply this directly. |
| classList | string[] | The same list as an array. |
The referenced recipes' own states ride along on their shared classes — a button carrying dt-colors-solid-brand gets that recipe's :hover for free. The component adds its own states on its delta class; both apply, and the delta wins at equal specificity by later source order. For precedence that doesn't depend on load order, emit into a cascade layer.
components.recipes: {
buttons: {
primary: {
colors: "solid.brand", typography: "button.base",
layout: "padding.button", effects: "surface.focusable",
css: { color: ref("colors.brand.text"), cursor: "pointer", border: "none", display: "inline-flex", gap: "8px" },
states: [{ state: "hover", css: { transform: "translateY(-1px)" } }],
},
ghost: { colors: "outline.brand", typography: "button.base", layout: "padding.button",
css: { background: "transparent" } },
},
cards: { default: { effects: "surface.card", layout: "padding.card", typography: "body.base",
css: { display: "flex", flexDirection: "column", gap: "10px" } } },
badges: { accent: { colors: "solid.accent", typography: "label.caps",
css: { display: "inline-flex", borderRadius: "9999px" } } },
}
theme.classes.components.buttons.primary // → { // className: "dt-colors-solid-brand dt-typography-button-base dt-layout-padding-button // dt-effects-surface-focusable dt-components-buttons-primary", // classList: [ …the five classes… ], // } button.className = theme.classes.components.buttons.primary.className;
/* the component emits ONLY its own delta class + own states … */ .dt-components-buttons-primary { color: var(--dt-colors-brand-text); /* "colors.brand.text" ref → var */ cursor: pointer; border: none; display: inline-flex; gap: 8px; } .dt-components-buttons-primary:hover { transform: translateY(-1px); } /* … the referenced classes bring their own rules & states: */ .dt-colors-solid-brand { background: var(--dt-colors-brand); color: var(--dt-colors-brand-text); } .dt-colors-solid-brand:hover { background: var(--dt-colors-brand-dark); } .dt-typography-button-base { /* … */ } .dt-layout-padding-button { /* … */ } .dt-effects-surface-focusable { /* … */ }
Globals
The base/element layer every subsystem above sits on — a normalization preset plus themed element rules. The one subsystem that mints no classes, styling bare elements only.
:where(…) preset layer (zero specificity) plus themed bare-element rules (a, a.subtle, headings). Themed values are token refs; headings bind to the type scale. The preview is sandboxed in a shadow root so those global element rules don't touch this page's own chrome.Overview
The globals subsystem (formerly reset) is the base/element layer — the only subsystem that mints no classes. It has two tiers: a preset (a static normalization base plus a default h1–h6 → type-scale map, rendered as zero-specificity :where(…) and ordered ahead of everything), and your themed elements (bare-selector rules at a higher tier that read token refs — now with states, responsive overrides and delta-only variants).
Authoring
The one form is an object — globals: { preset, elements }, both optional (no bare-string shorthand). The preset must be explicit for the static + default-heading layers to emit; a bare { elements } emits only your element rules, so an override() child inherits the parent's preset instead of reverting.
| Field | Type | Description |
|---|---|---|
| preset | "preflight" | "normalize" | "reset" | false | The static normalization + default-heading layers (see below). false disables them (elements only). |
| elements | Record<selector, GlobalsElement> | Themed bare-selector rules. Each leaf is literal-first — a bare string is a raw literal, and ref("colors.brand") / { ref: "…" } is a token reference. An element may carry states, responsive, and delta-only variants. No cross-subsystem composition refs, no modes (dark rides the referenced token's own modes). A ref() at an unknown token throws at build. |
Presets
| Preset | Behaviour |
|---|---|
| preflight | Opinionated, token-first — strips UA styling (box-sizing, zero margins, unstyled headings/lists/anchors) so the design tokens are the sole source. Includes the default-heading map. |
| normalize | Light — fixes a few cross-browser bugs but preserves UA defaults (no margin zeroing / heading strip, no default-heading map). |
| reset | Aggressive classic — zeroes margin/padding/border on everything and unstyles headings & lists. Includes the default-heading map. (A preset value, not the old subsystem name.) |
| false | Disables the static + default-heading layers; only your elements emit. |
Default headings → the type scale
Because preflight / reset strip heading sizes to inherit, the preset re-binds h1–h6 to typography's fontSize scale. It's opportunistic — a heading whose scale step wasn't generated (a ratio-less fontSize) is dropped.
| Element | Binds font-size to |
|---|---|
| h1 | typography.fontSize.4xl |
| h2 | typography.fontSize.3xl |
| h3 | typography.fontSize.2xl |
| h4 | typography.fontSize.xl |
| h5 | typography.fontSize.lg |
| h6 | typography.fontSize.md |
Output — two tiers
The preset lowers to kind: "reset" rule-sets rendered as :where(sel){…} — zero specificity, so any class trivially overrides them, and ordered ahead of every recipe and variable. Your themed elements lower to kind: "globals" rule-sets rendered as bare selectors at a higher tier: a, a :hover state as a:hover, a responsive override in an @media, and a variant self-scoped as a.subtle. No classes are minted either way.
globals: {
preset: "preflight",
elements: {
a: {
color: ref("colors.brand"), // ref() = token reference
textDecoration: "underline", // bare string = literal
states: [{ state: "hover", color: "colors.brand.dark" }],
responsive: [{ breakpoint: "md", query: "min", fontSize: "typography.fontSize.lg" }],
variants: { subtle: { color: "colors.scale.500" } }, // → a.subtle (delta-only)
},
h1: { color: "colors.ink" },
blockquote: { color: "colors.accent", borderColor: "colors.accent" },
},
}
/* preset — static normalize (:where = zero specificity) */ :where(*,::before,::after) { box-sizing: border-box; border-width: 0; border-style: solid; } :where(h1,h2,h3,h4,h5,h6) { font-size: inherit; font-weight: inherit; margin: 0; } :where(a) { color: inherit; text-decoration: inherit; } /* preset — default headings → the type scale */ :where(h1) { font-size: var(--dt-typography-fontsize-4xl); } /* … through h6 */ /* your themed elements — BARE selectors, higher tier */ a { color: var(--dt-colors-brand); text-decoration: underline; } a:hover { color: var(--dt-colors-brand-dark); } a.subtle { color: var(--dt-colors-scale-500); } @media (min-width: 768px) { a { font-size: var(--dt-typography-fontsize-lg); } } h1 { color: var(--dt-colors-ink); } blockquote { color: var(--dt-colors-accent); border-color: var(--dt-colors-accent); }
Token paths & naming
Everything in a theme has one format-neutral identity — its token path. The path is what theme.tokens keys, what resolveToken takes, and what every ref points at. Adapters then render that path into their own names — the CSS adapter into a --dt-… variable, the JSON adapter into a key, and so on. The path is the constant; the prefix is per-adapter.
colors.brand.dark) is treated as a public API: it won't change or disappear within a minor or patch release, so agents and downstream code can bind to it and DTCG documents round-trip on it. Path additions are minor; a removal or rename is a breaking (major) change, called out in the release notes. The rendered name (--dt-…) is one adapter's output, not the identity.The token path
A path mirrors what you authored, dot-separated. Two shapes cover everything:
| Kind | Shape | Examples |
|---|---|---|
| Property | <subsystem>.<property>[.<variant>] | colors.brand · colors.brand.dark · colors.brand.text · effects.shadow.lg |
| Rule-set (recipe) | <subsystem>.<group>.<variant> | colors.solid.brand · typography.heading.h1 · components.buttons.primary |
Synthesized things get paths too — a tonal step you didn't author, like colors.brand.dark, is addressable the moment it's derived. And refs are just paths: a colour variant's ref, a ref("…") in a component's css delta, a globals element's ref("…") — each names a path. That's the whole reference system.
theme.tokens // a flat path → Ref map, keyed by these paths theme.resolveToken("colors.brand.dark") // → "rgb(59, 91, 219)" // a recipe references a path — no CSS anywhere in the Model: components: { recipes: { buttons: { primary: { css: { color: ref("colors.brand.text") } } } } }
How each adapter renders a path
An adapter turns a path into whatever its format needs. Same path, different names — this is what keeps the Model format-neutral:
| Adapter | Property colors.brand.dark → | Rule-set components.buttons.primary → |
|---|---|---|
| CSS | --dt-colors-brand-dark (a :root var) | .dt-components-buttons-primary (a class) |
| SCSS | $dt-colors-brand-dark | .dt-components-buttons-primary |
| styled-components | --dt-colors-brand-dark | .dt-components-buttons-primary |
| JSON | "colors.brand.dark" (keyed by the path itself) | "components.buttons.primary" |
The text adapters (CSS · SCSS · styled-components) flatten the path — dots to dashes — and add a prefix. The JSON adapter keeps the path verbatim as the key. A custom adapter names however its target needs. So a --dt-… name is one adapter's rendering, not the identity.
The text-adapter prefix
Only the text adapters carry a prefix. The default is dt (think design token); two options control it — nothing else about the naming changes:
| Option | Default | Controls |
|---|---|---|
| prefix | "dt" | The leading segment of every variable — --<prefix>-<path>. |
| classPrefix | = prefix | The leading segment of every class — .<classPrefix>-<path>. Falls back to prefix. |
const theme = createTheme(raw, { adapter: createCssAdapter({ prefix: "acme" }) }); // same paths → --acme-colors-brand-dark … .acme-components-buttons-primary
Segments are sanitized into identifiers — lowercased, non-identifier characters collapsed to a dash (a palette named "Brand Accent" → brand-accent) — but the path itself stays the canonical key. Container-context classes follow the same rule: .<classPrefix>-cq-<name>.
--dt-… names shown throughout this site are the CSS adapter (the in-package default). The neutral identity behind each is the token path — flip the adapter tabs to see the same theme named other ways. Prefix options live on each adapter: CSS · SCSS.Naming overrides — full control of names
When a prefix isn't enough — a host framework expects a specific class stem, or you need to match an existing variable convention — the two text adapters (createCssAdapter and createStyledComponentsAdapter) take an optional naming option: two formatters that each receive the structured address plus the computed default, and return a name to decorate or replace. Return defaults.name for the cases you don't want to touch — omit naming entirely and output is byte-identical.
| Formatter | Address | Covers |
|---|---|---|
| className | { kind, subsystem, group, variant } | Every class — recipe classes, and (via kind: "container") the -cq-<name> container-context utilities. |
| variableName | { path, segments } | Every variable — its :root definition and every var(--…) usage. |
const theme = createTheme(raw, { adapter: createCssAdapter({ naming: { // stem component classes as app-<group>-<variant>; leave the rest default className: (a, d) => a.subsystem === "components" ? `app-${a.group}-${a.variant}` : d.name, // rename colour variables --brand-…; other subsystems keep --dt-… variableName: (a, d) => a.segments[0] === "colors" ? `--brand-${a.segments.slice(1).join("-")}` : d.name, }, }) });
--brand-primary: rgb(77, 171, 247); /* :root definition */ .app-button-primary { background: var(--brand-primary); } /* usage + own class */ // theme.getClass("components","button","primary") → "dt-colors-solid-primary app-button-primary" // (the referenced colours class stays default; only the own delta class is remapped)
Each result is held to a three-part contract: deterministic (a pure function of the address — definitions and usages call it repeatedly), collision-free (two distinct addresses producing one name throw), and a valid identifier (run through the segment sanitizer; variableName is normalized to -- + a sanitized body). One remap is applied at both pure choke points, so a variable's definition and every reference, and a recipe's class rule, its resolved classList / component-reference lookup, theme.classes and getClass, all stay in lock-step. CSS and styled-components share this one path; the JSON adapter keys its data its own way and stays out.
naming-invariants.test.ts + naming-collision.test.ts. And theme.override() leaves the parent byte-identical while sharing every untouched Model branch (structural sharing) — covered by override.test.ts. The suite runs on every change in CI.Precedence & cascade layers
refract emits low, even specificity — recipe rules are single classes and globals are zero-specificity :where() — and relies on source order at equal specificity (the later rule wins). That keeps its output easy to override with your own CSS, but source order depends on where the stylesheet loads.
@layer. The CSS adapter's layer option wraps all output in a named layer (layer: true → @layer refract, or pass a name). Layered rules always lose to unlayered CSS, so your app styles win by default no matter the order they load in — while refract's internal ordering stays deterministic inside the layer. It's single-file emit only and off by default (output is byte-identical unless you opt in). This is the recommended way to compose refract with an existing stylesheet or a utility framework.Responsive
Any property or recipe can carry a responsive list. refract lowers each entry to an @media override — on the :root variable for token properties, or on the class for recipes.
Breakpoints are named (xs … xl); each entry picks one with a min or max query. The emitted media unit (px / em / rem) is a build-time config, so authoring stays unit-agnostic.
// declare the named scale once, on the theme breakpoints: { xs: 0, sm: 576, md: 768, lg: 1024, xl: 1440, }
Then any property or recipe references those names in a responsive entry:
spacing: {
base: 8, variants: { xl: 28 },
responsive: [
{ breakpoint: "sm", query: "min", target: "xl", base: 40 },
],
}
// lowers to → @media (min-width: 576px) { :root { --…-spacing-xl: 40px } }
Worked example
A recipe that changes its background above a breakpoint — authored, then exactly what the CSS adapter emits:
solid.brand: {
background: "brand", color: "brand.text",
responsive: [{ breakpoint: "md", query: "min", background: "accent" }],
}
.dt-colors-solid-brand { background: var(--dt-colors-brand); color: var(--dt-colors-brand-text); } @media (min-width: 768px) { .dt-colors-solid-brand { background: var(--dt-colors-accent); } }
Rules & edge cases
- Property vs recipe. A property's responsive lowers to a
:rootvariable override (@media { :root { --… } }); a recipe's lowers to a class override, as above. Same authoring, two lowerings. - Query kind. Each entry is
min,max, orexact— commit to mobile-first (min) or desktop-first (max) deliberately. - Source order is cascade order. Entries emit after the base in author order; at equal specificity the later rule wins, so order breakpoints so the intended one lands last.
- Composes with the other axes. A responsive entry may also carry a
state(→@media { .cls:hover { … } }); container queries are a separate axis. - Unknown breakpoint is a build error — declare it in
breakpointsfirst.
responsive. The emitted unit (px/em/rem) is the media config. See Container queries for the container axis and States for combining with states.Live
Container queries
A top-level containers config declares named query containers. A recipe override keyed by { container, size } lowers to an @container rule — styling by the container's width, not the viewport's.
The emitted .dt-cq-card utility establishes the context (container-type: inline-size); any descendant recipe responds. It's a distinct condition axis that composes with viewport @media.
containers: { card: { sizes: { sm: 280, md: 440 } } },
// a recipe override, keyed by container + size
solid.accent: {
background: "accent",
responsive: [
{ container: "card", size: "md", background: "brand" },
],
}
Worked example
containers: { card: { type: "inline-size", sizes: { sm: 280, md: 440 } } },
solid.brand: {
background: "brand",
responsive: [{ container: "card", size: "md", background: "brand.dark" }],
}
/* the context class establishes the container */ .dt-cq-card { container-type: inline-size; container-name: card; } /* the recipe override responds to the CONTAINER's width, not the viewport's */ @container card (min-width: 440px) { .dt-colors-solid-brand { background: var(--dt-colors-brand-dark); } }
Rules & edge cases
- You must place the context class. A descendant only responds if an ancestor carries
.dt-cq-<name>— that's what establishescontainer-type: inline-size. - Inline size only. Container queries respond to the container's width;
orientation/ height / aspect-ratio are rejected as a build error (that's what viewport@mediais for). - min is the default query for container entries.
- A separate axis from
@media. Container and viewport queries compose — a rule can be inside both. - Unknown container or size is a build error — declare them under
containers.
.dt-cq- naming is in Token paths & naming.Live — resize the container
States
A recipe's states map compiles to selector rules — :hover, [disabled], focus, and so on. The adapter owns the set of known states; core validates every state: ref against it (so a typo is a build error, not silent CSS).
solid.brand: {
background: "brand", color: "brand.text",
states: [
{ state: "hover", background: "brand.dark" },
{ state: "disabled", background: "brand.lighter", color: "brand.dark" },
],
}
Worked example
.dt-colors-solid-brand { background: var(--dt-colors-brand); color: var(--dt-colors-brand-text); } .dt-colors-solid-brand:hover { background: var(--dt-colors-brand-dark); } .dt-colors-solid-brand[disabled] { background: var(--dt-colors-brand-lighter); color: var(--dt-colors-brand-dark); }
Each state key maps to a selector — hover → :hover, disabled → [disabled] — on the recipe's own class.
Rules & edge cases
- The adapter owns the known states. The CSS adapter knows
hover,disabled, focus, … ; an inline or JSON adapter may know none. A state the target adapter doesn't declare is a build error — not silent CSS. - States ride on the shared class. A component that references a recipe inherits that recipe's
:hoveretc. for free — see Components. - Own delta wins. A component's own state delta and a referenced recipe's state both apply; the delta wins at equal specificity by later source order.
- Composes with responsive. A responsive entry may carry a
state→@media { .cls:hover { … } }.
Live — hover & disabled
Variants & targets
A property's variants become suffixed tokens (--…-dark, spacing-xl). In a responsive entry, variant: swaps a recipe to a sibling's base, and target: scopes the override to one specific step.
outline.brand: {
color: "brand", borderColor: "brand",
responsive: [
{ breakpoint: "lg", variant: "subtle" }, // adopt sibling ≥lg
],
}
// target: scope an override to ONE step
spacing.responsive: [{ breakpoint: "sm", target: "xl", base: 40 }]
variant — swap to a sibling
In a recipe responsive entry, variant makes the recipe adopt a sibling variant's declarations above the breakpoint.
solid: {
brand: { background: "brand", color: "brand.text",
responsive: [{ breakpoint: "lg", query: "min", variant: "muted" }] },
muted: { background: "brand.light", color: "brand.dark" },
}
/* ≥lg, solid.brand takes on solid.muted's declarations */ @media (min-width: 1024px) { .dt-colors-solid-brand { background: var(--dt-colors-brand-light); color: var(--dt-colors-brand-dark); } }
target — scope to one token
In a property responsive entry, target scopes the override to a single variant token, leaving the base untouched.
surface: {
base: "#ffffff", variants: { raised: "#f5f5f5" },
responsive: [{ breakpoint: "sm", query: "min", target: "raised", base: "#e9ecef" }],
}
:root { --dt-colors-surface: rgb(255, 255, 255); --dt-colors-surface-raised: rgb(245, 245, 245); } @media (min-width: 576px) { :root { --dt-colors-surface-raised: #e9ecef; } /* only the raised token */ }
Rules & edge cases
- Opposite meanings.
variantnames a source to adopt (a whole sibling recipe);targetnames a destination to scope to (one token). Setting both on one entry is a build error. - variant is a recipe thing; target is a property thing. They live in different lowerings — a class swap vs a single-var override.
- Variant tokens are always addressable. A property's variants emit as
--…-<variant>alongside the base, whether or not any responsive entry touches them.
variant: | target: | |
|---|---|---|
| Names a | source — a whole sibling recipe to adopt | destination — one token to scope a value to |
| Operates on | a recipe (a class swap) | a property (a single-var override) |
| Lowers to | a different .dt-… class at the breakpoint | a --… var override inside a :root media block |
| Reach for it when | a component should become a different variant at a breakpoint | one token needs a different value at a breakpoint |
responsive entry — see Responsive. Variant naming follows Token paths & naming.Live
Recipe variants
Recipes come in three tiers: a recipe group (solid) is a family; a recipe (brand) is a named bundle of refs + css that resolves to a class; a variant is an optional modifier delta layered on a recipe. A recipe's variants map desugars to flat sibling recipes named <recipe>-<variant> — DRY siblings that share a base. Opt-in and additive on every subsystem's recipes; no variants key ⇒ output is byte-identical.
buttons: {
primary: { // the recipe — its own props ARE the base
colors: "solid.primary", typography: "button.large", borders: "box.default",
css: { gap: "8px" },
variants: { // modifiers on THIS recipe
sm: { typography: "button.small", css: { gap: "6px" } },
flat: { borders: null }, // null drops the inherited ref
},
},
}
.dt-components-buttons-primary /* the bare recipe still emits */ .dt-components-buttons-primary-sm /* base + sm delta */ .dt-components-buttons-primary-flat /* base, minus the borders ref */
How a variant merges onto the recipe
- A ref / scalar prop replaces — a ref is one atomic pointer; if the variant sets it, it wins, else it inherits.
cssshallow-merges by property —{ …base.css, …delta.css }, sosmnudges onlygap.statesmerge by state name — union of keys; a shared state's declarations shallow-merge (delta wins).responsive[]concatenates —base ++ delta, delta appended (higher source order).
Rules & edge cases
nullremoves,"none"keeps. A prop set tonulldrops an inherited ref (borders: null). Not"none"— that stays a real value (border: none, effects"none", theradius.nonevariant).- Flat axis, no nesting — matching token variants. Multi-axis modifiers are out of scope.
- Collisions throw. A desugared
<recipe>-<variant>that collides with an existing sibling recipe is a build error — never a silent last-wins. - Same pattern as properties. A responsive
variant:/target:on a recipe references a sibling recipe — and because variants desugar into group members,target: "primary-sm"resolves against the same pool. See Variants & targets.
variants map is a reserved recipe-leaf key alongside states / responsive. A pre-pass in the shared recipe normalizer expands it before anything downstream runs, so the Model and every adapter are unchanged.Length units
A length's unit is a property of the value, not a global switch. You author magnitudes; a single build-time units config resolves each length to a concrete { value, unit } — once, format-neutrally — so every adapter (CSS, SCSS, JSON, DTCG) emits the same unit.
A length can be written three ways. A bare number is deferred — its unit is resolved from units. A <number><unit> string is pinned — trusted verbatim, never converted. Anything else (calc(), clamp(), var(), a keyword) is a raw-string escape and passes through untouched.
createTheme(raw, { adapter: createCssAdapter(), units: { default: "px", // global fallback typography: "rem", // fontSize → rem layout: "rem", // layout lengths — spacing / gutters / sizes borders: "px", // hairlines stay crisp }, baseFontSize: 16, // divisor when a deferred length → rem }); // fontSize 24 → "1.5rem" · borders.width 1 → "1px" · letterSpacing "-0.02em" → verbatim
Keys are token-path prefixes; the most-specific wins, over a built-in seed. So units.typography = "rem" converts fontSize, but the seed keeps lineHeight unit-less and letterSpacing in em unless you override them at the property grain.
The same units and baseFontSize are accepted at run-time on createTheme (above) and at build-time on defineConfig — so refract build resolves lengths identically and emits the chosen unit straight to disk.
| Resolution order (a length leaf's unit) | Example key |
|---|---|
| ① value-level unit (pinned) | "1.5rem" |
| ② user property grain | units["typography.letterSpacing"] |
| ③ built-in seed (property grain) | lineHeight → none · letterSpacing → em |
| ④ user subsystem grain | units["typography"] |
| ⑤ user global | units.default |
| ⑥ built-in fallback | px |
Length leaves covered: typography fontSize / letterSpacing / lineHeight, layout spacing / gutters / sizes, borders width / offset / radius, effects blur and shadow geometry. Non-lengths (opacity, z-index, aspect-ratio, transition/animation ms) are never touched.
÷ baseFontSize conversion runs only when a deferred magnitude resolves to rem; a pinned "1.5rem" is never divided, and a pinned "24px" stays px even in a rem theme. This is not the media / container-query threshold unit — those live in the media config, which additionally supports the query-evaluation em.Top-level registries
A handful of top-level keys are registries — declared once, referenced by name everywhere else (the name is typo-validated, like a scale). Everything else at the top level is a subsystem slice.
[bracketed] = an author-chosen name; reserved words (base, variants, modes, ref, …) are literal; "value" / 0 = a leaf value. This is the generic shape — the subsystem pages carry the worked examples.{
breakpoints: { "[breakpoint name]": 0 }, // name → min-width px
containers: { "[container name]": { type: "inline-size", sizes: { "[size name]": 0 } } },
modes: [ "[mode name]" ], // appearance-mode registry (defaults to ["dark","light"])
extends: { prefix: "[parent var prefix]" }, // for external tokens (default "dt")
"[subsystem]": { /* … a subsystem slice … */ }, // colors / typography / layout / …
}
| Registry | Shape | Referenced by |
|---|---|---|
| breakpoints | name → px | A responsive entry's breakpoint (Responsive). Omit for the engine defaults. |
| containers | name → { type?, sizes } | A recipe { container, size } override (Container queries). type defaults to inline-size. |
| modes | string[] | A property modes entry's mode (Override axes). An undeclared mode throws. dark/light bind to prefers-color-scheme; any other name is a [data-theme] toggle. |
| extends.prefix | string | The parent theme's CSS-variable prefix, used to lower a path-form external token (Values). |
responsive breakpoint, container name, or mode that isn't declared is a build error, not silent output.Values — the generic leaf
A value is the atom every field holds. Four leaf forms; each subsystem restricts which it accepts (colours take hex/tuple/CSS-colour, scalars take numbers, …). external is a whole-property passthrough, not a leaf.
"value" // string — a literal value / keyword 0 // number — a unitless literal; the subsystem gives it meaning [ 0, 0, 0 ] // tuple — fixed-length list (today only the [r,g,b] colour tuple) { "[leaf field]": "value" } // object leaf — flat field map (shadow/transition); an array of these = multi-part { external: "[token path]" } // external — a whole-property passthrough (see below)
References
In composition fields (recipe props) a bare string names a token, ref-first. In a css block it is literal-first — a bare string is raw CSS; mark a token with ref("[path]") or { ref: "[path]" }. See Token paths & naming.
External tokens
An external value aliases a variable a parent theme owns — { external: "[token path]" } lowers to var(--[prefix]-[token-path]) (path form, via extends.prefix), or { external: "--[some var]" } for a literal var (leading --). It replaces the whole property, is referenceable everywhere (including recipes), is never defined locally, and survives override(). See White-labeling.
var(--…) is not a valid colour leaf — it can't be tonally derived at build. Borrow it as an external token instead.Properties
A property is a uniform map of named fields (each → a CSS variable) plus the three override axes. The reserved field base is the primary value → the unsuffixed --<prop> var; any other name is an extra → --<prop>-<field>.
// (a) bare form — shorthand for { base: value }. Single-primary-field props only. "[property name]": "value", // (b) full form — a field map + the override axes. "[property name]": { base: "value", // the primary field → the unsuffixed var "[field name]": "value", // an extra field → the --…-[field] var variants: { "[variant name]": { /* override set */ } }, // §4 · a NEW named token per variant modes: [ { mode: "[mode name]", /* override set */ } ], // §4 · a conditional re-declaration per mode responsive: [ { breakpoint: "[breakpoint name]", /* override set + routing */ } ], }
- One uniform field map. Every field is the same kind (a name → a var);
baseis just the reserved primary. Adapters emit one variable per field. - Object-leaf props omit
base.shadow/transitionscompose the primary value from flat leaves (Effects), so the bare form isn't available for them. - The axes carry override sets.
variants,modes, andresponsiveeach hold an override set — see Override axes for what each does.
steps / harmony, layout's ratio / steps, animation's keyframes. See each subsystem page.Override sets
The value carried by each entry of variants / modes / responsive is an override set — one of three forms. The two object forms are mutually exclusive: base/fields and ref never coexist.
// (a) bare — just the primary value (variants / modes only) "value" // (b) literal object — the same field map as a property; omitted fields INHERIT the property's { "[field name]": "value" } // (c) derivation — read `ref`, then fold the `modifiers` chain over it (mutually exclusive with b) { ref: "[source token]", modifiers: [ { "[fn]": "[args]" } ] }
refdefaults to the own base. Omit it to derive from the property's own primary value; name another variant/step, or a cross-property token path — resolved post-build and re-derived onoverride().modifiersis an ordered chain. Single-key dials applied left-to-right (colours'{ darken }/{ lighten }/{ alpha }/{ adjust }— see Colors). Nomodifiers= a plainrefswap.- Base is optional and inherits. A literal set may override only an extra field, leaving the primary as-is.
{ ref, modifiers } derivation is the value form for a variant, a mode, and a responsive entry — one grammar across all three axes.Override axes
variants, modes, and responsive all carry override sets — they differ in what the engine does with them. modes / responsive (and recipe states) are arrays of overrides over one spine: WHEN (mode/state/breakpoint) · WHERE (target) · WHAT (the override set). variants is a map, because the name mints an identity.
variants: { // MAP — each key mints a NEW named token
"[variant name]": { /* override set */ },
},
modes: [ // LIST — a conditional re-declaration per mode
{ mode: "[mode name]", /* override set */ },
{ mode: "[mode name]", target: "[variant name]", /* … */ }, // target → scope into a variant's var
],
responsive: [ // LIST — override set + routing
{ breakpoint: "[breakpoint name]", query: "min|max|exact", ref: "[variant name]" },
{ breakpoint: "[breakpoint name]", mode: "[mode name]", /* override set */ },
]
| Axis | Shape | Effect |
|---|---|---|
| variants | map | Each entry mints a new named token referenceable anywhere. Base optional → inherits. See Variants & targets. |
| modes | list | A conditional re-declaration of the property's vars per mode (from the registry); target scopes it into a variant's var. Adapters realize it as prefers-color-scheme / [data-theme]. |
| responsive | list | Override at a breakpoint (+ query/orientation/mode). ref (read) and target (write) compose. See Responsive. |
target. On a mode it scopes the override onto a variant's var; on a recipe state it scopes onto a <item>-<variant> sibling — so two same-condition entries can behave differently. Omit → the base var / base rule.Recipes
A subsystem's recipes block bundles declarations that reference tokens (recipes never define tokens). Two levels: group → item, each item → one class. Its axes mirror a property, one level up.
recipes: {
"[group name]": { // group
"[item name]": { // item → one class
"[recipe prop]": "[variant name]", // REF-FIRST props (name a variant)
states: [ { state: "[state name]", target: "[recipe variant]", /* prop deltas */ } ],
responsive: [ { breakpoint: "[breakpoint name]", state: "[state name]", /* prop deltas */ } ],
variants: { "[recipe variant]": { /* prop deltas */ } }, // → sibling recipe [item]-[recipe variant]
},
},
}
states/responsiveare lists of override entries ({ state, target?, … }/ routing) — same spine as property modes. See States.variantsdesugars each modifier into a flat sibling[item]-[variant](a new class), so astate/responsivetargetscopes onto it. See Recipe variants.componentscompose. A component item references OTHER subsystems' recipes and adds its owncssdelta — see Components.
components: { recipes: { "[group name]": {
"[item name]": {
"[subsystem]": "[group.item]", // reference another subsystem's recipe (REF-FIRST)
css: { "[css prop]": "value", "[css prop]": { ref: "[token path]" } }, // LITERAL-FIRST; ref for tokens
},
} } }
A full theme
The shapes from every page above, nested into one RawTheme — registries, a property with all three axes, a derivation, an external token, and a recipe. Generic throughout ([bracketed] = author-chosen; "value" = a leaf).
{
breakpoints: { "[breakpoint name]": 0 },
modes: [ "[mode name]" ],
extends: { prefix: "[parent var prefix]" },
"[subsystem]": {
"[property name]": {
base: "value", "[field name]": "value",
variants: { "[variant name]": { ref: "[source token]", modifiers: [ { "[fn]": "[args]" } ] } },
modes: [ { mode: "[mode name]", base: "value" } ],
},
"[external property]": { external: "[token path]" },
recipes: { "[group name]": { "[item name]": {
"[recipe prop]": "[variant name]",
states: [ { state: "[state name]", "[recipe prop]": "[variant name]" } ],
} } },
},
components: { recipes: { "[group name]": { "[item name]": {
"[subsystem]": "[group.item]", css: { "[css prop]": "value" },
} } } },
}
Adapters — one theme, many formats
You author one RawTheme; it lowers to a format-neutral Model. An adapter refracts that Model into a concrete format. The Model holds no CSS syntax — the adapter owns all naming, units and output — so the same theme can emit CSS, SCSS, JSON tokens, or styled-components, side by side. And the four built-ins aren't a ceiling: an adapter is a standalone package against a stable contract, so you (or the community) can target any format — React Native, Compose, a token pipeline — without touching core.
| Adapter | Package | Emits | |
|---|---|---|---|
| CSS | refract-css | A stylesheet — :root vars + recipe classes (theme.css / .classes) | CSS → |
| SCSS | refract-scss | Sass source — $variables + classes (theme.scss) | SCSS → |
| JSON | refract-json | refract's own token document — tokens + rule-sets (theme.json) | JSON → |
| styled-components | refract-styled-components | TS/JS theme modules — a theme object + css recipes + GlobalStyle | SC → |
The adapter is a required argument to createTheme — core ships no default, which is what keeps the Model format-neutral. All four run in the browser and at build time; every preview on this site is compiled live by the CSS adapter.
The same theme, four formats — live
Below is the current preset — the exact RawTheme behind every panel on this site — refracted through each adapter in your browser. One source object; four outputs. Switch the preset in the top bar and all four re-emit.
RawTheme, no per-format authoring — the Model is format-neutral and each adapter owns its own naming, units and syntax. That's the whole thesis. Compare them side by side, or read each adapter in depth: CSS · SCSS · JSON · styled-components.CSS adapter createCssAdapter
Renders a Theme to a plain-CSS stylesheet — :root custom-property blocks plus the recipe class rules that reference them — and adds a class surface for using them in markup. The full surface it adds to the theme is below.
import { createTheme } from "@theme-registry/refract"; import { createCssAdapter } from "@theme-registry/refract-css"; const theme = createTheme(raw, { adapter: createCssAdapter({ prefix: "acme" }) }); theme.css; // the full stylesheet
Every option is optional. Two prefix knobs — one for variables, one for classes — cover the common case (for full control there's the naming override), plus value/delivery knobs. Subsystems are namespaced by the token path, so by default a variable is --<prefix>-<subsystem>-<group>-<variant> and a recipe class is .<classPrefix>-<subsystem>-<group>-<variant>.
The theme surface
On top of the base theme (model · tokens · resolveToken · override, from core), the CSS adapter adds:
| Member | Type | What it is |
|---|---|---|
| css | string | The full stylesheet — :root variable blocks + every recipe class rule. |
| variablesCss | string | Just the :root custom-property blocks. |
| recipesCss | string | Just the recipe class rules. |
| classes | Record<sub, Record<group, Record<variant, …>>> | The class map — subsystem → group → variant. A component variant is { className, classList }; every other subsystem's recipe is a class-name string. |
| getClass | (sub, group, variant) => string | undefined | The ergonomic accessor over classes: the class string for one address (the composed className for components), or undefined if the address doesn't exist. |
| varName | (path) => string | undefined | A token path's emitted CSS custom-property name — varName("colors.brand.dark") → --dt-colors-brand-dark (carrying the configured prefix), or undefined for a path that mints no variable. The var-name twin of resolveToken's value. |
| renderRecipe | (sub, group, variant) => string | One recipe's own CSS — base declarations + its state / breakpoint rules. The finer-grained sibling of css. |
| nodes | CssNode[] | The structured CSS-node IR behind css — for custom rendering or tooling. |
| media | MediaDescriptor | Breakpoint → @media builder (the same descriptor used to lower responsive rules). |
theme.getClass("components", "buttons", "primary"); // → "dt-colors-solid-brand … dt-components-buttons-primary" theme.getClass("colors", "solid", "brand"); // → "dt-colors-solid-brand" theme.getClass("colors", "nope", "x"); // → undefined
Helper functions
Beyond the stylesheet, the adapter hands you helpers for the two places you write CSS outside a recipe — at run time, and after a build-time emit.
Run-time — theme.media
A media-query builder over your named breakpoints, so custom CSS (or a styled block) stays in sync with the theme instead of hard-coding pixel widths. It returns the @media string; max / between use the next breakpoint's edge automatically.
theme.media.min("md"); // "@media (min-width: 768px)" theme.media.max("md"); // "@media (max-width: 1023.98px)" theme.media.between("sm", "lg"); // "@media (min-width: 576px) and (max-width: 1023.98px)" // per-breakpoint group — precomputed strings: theme.media.md.min; // "@media (min-width: 768px)"
media config, so these strings match what your recipes' responsive rules emit. (The styled-components adapter decorates theme.media as tagged templates for styled blocks.)Build-time — vendored color-math
When you emit static CSS and drop refract from the runtime, you sometimes still need to compute a colour live (a user-picked accent, say). Opt a build target into the color-math helper and refract writes a standalone color-math.js next to your CSS — the exact functions it used to synthesize your palette, so a value computed in the browser matches the emitted variables.
{ adapter: createCssAdapter(), outDir: "./theme", helpers: ["color-math"] }
// → ./theme/color-math.js (no refract dependency)
import { lighten, darken, alpha } from "./theme/color-math.js";
lighten("#4c6ef5", 14); // same maths as the emitted --dt-colors-…-light
| Helper | Exports | Why |
|---|---|---|
| color-math | lighten · darken · alpha · setL · rotateHue · complement · adjust · rgbToOklch · oklchToRgb · toHexColor · toOklchColor · convertHexToRGB · convertRgbToHex | The pure colour maths refract synthesizes steps with — so live values match the emitted CSS. Self-contained (zero refract import). The same functions (plus parseColor/serializeColor/isHexColor) are importable directly from the @theme-registry/refract/color-math subpath when you keep refract installed. |
helpers: [ … ]. Adapters can also ship theme-specific vendored helpers automatically (the SC adapter emits a baked media module); the CSS adapter needs none, so its only vendored helper is the opt-in color-math.Options
| Option | Type | Default | Affects |
|---|---|---|---|
| prefix | string | "dt" | Every variable name (--<prefix>-…); also the default for classPrefix. |
| classPrefix | string | = prefix | Every class name — recipe classes and container-query context classes. |
| inline | boolean | false | Bakes resolved values into rules and drops the :root variable blocks. |
| colorFormat | "rgb" | "hex" | "oklch" | "rgb" | Output syntax for palette colour variables (see below). |
| naming | { className?, variableName? } | — | Full class / variable name control beyond the prefix — two formatters over the structured address. See Naming overrides. |
| layer | string | boolean | — | Wrap all output in a named cascade @layer (true → refract) for deterministic precedence below unlayered app CSS. Single-file emit only; off = byte-identical. Also on the SCSS adapter. |
| reducedMotion | boolean | false | Append a @media (prefers-reduced-motion: reduce) block that neutralizes animation + transition durations. |
colorFormat — palette colour output
Choose how palette colour values are written into the :root variables — base, text, variants, responsive overrides, and appearance modes. The colour is identical across formats; this is presentation only. The Model always stores the canonical rgb(); hex / oklch re-serialize that same colour at emit time. oklch unlocks the browser's native OKLCH interpolation for anything reading these variables.
createCssAdapter(); // --dt-colors-primary: rgb(77, 171, 247); createCssAdapter({ colorFormat: "hex" }); // --dt-colors-primary: #4dabf7; (#rrggbbaa with alpha) createCssAdapter({ colorFormat: "oklch" }); // --dt-colors-primary: oklch(71.8% 0.1422 246.06);
oklch() reflects the 8-bit-quantized value (the rgb() is the canonical colour). Colours typed literally into a recipe pass through as authored — colorFormat applies to synthesized palette tokens; inline mode inherits the format. Other adapters (SCSS / JSON / styled-components) and DTCG export are unaffected.prefix — variable names (and the MFE story)
prefix sets the identifier segment on every CSS variable, and is the default for classPrefix, so on its own it rebrands the whole output:
:root { --acme-layout-spacing: 16px; } .acme-layout-container-full { gap: var(--acme-layout-spacing); }
Micro-frontend isolation. Two independently-built bundles on one page must not share variable names, or their --* custom properties collide. Give each build a distinct prefix:
// App A createCssAdapter(); // → --dt-* / .dt-* // App B createCssAdapter({ prefix: "checkout" }); // → --checkout-* / .checkout-*
Because a variable's first segment is the prefix by construction, choosing the prefix is the whole isolation mechanism — there's no separate “scope” concept.
classPrefix — class names
By default classes inherit prefix. Set classPrefix only when you want the class names to differ from the variable names:
:root { --acme-layout-spacing: 16px; } /* variables → prefix */ .ui-layout-container-full { gap: var(--acme-layout-spacing); } /* classes → classPrefix */
This one prefix covers every class the adapter emits, including the container-query context utility classes — there is no per-family override:
.ui-cq-card { container-type: inline-size; container-name: card; }
inline — bake values, drop variables
By default the adapter emits var(--…) references plus the :root blocks that define them. inline: true resolves each reference to its concrete value, bakes it into the declaration, and omits the variable blocks entirely.
/* default */ :root { --dt-colors-primary: #4dabf7; } .dt-colors-solid-primary { background: var(--dt-colors-primary); } /* inline: true */ .dt-colors-solid-primary { background: #4dabf7; }
:root var and everything updates); use inline for a self-contained stylesheet with no custom-property indirection. It leaves no variables file, so it can't be combined with the multi-file emit modes that produce a separate variables file (the adapter throws). Note the components emit mode has its own inline control (default true) on the emit plan; this global inline doesn't drive it.Value units
The length unit for declaration values is not a CSS-adapter option — it's resolved format-neutrally in core from createTheme's units config and baked onto the Model, so every adapter emits the same unit. See Length units.
SCSS adapter createScssAdapter
Emits Sass source — $variables + classes ($<prefix>-…, .<prefix>-…). Surfaces: theme.scss · .classes.
| Option | Type | Default | Description |
|---|---|---|---|
| prefix | string | "dt" | Identifier prefix for $variables and class names. |
| inline | boolean | false | Inline resolved values instead of $var references. |
| indent | number | 2 | Indentation width (spaces). |
| layer | string | boolean | — | Wrap all emitted SCSS in a named cascade @layer (true → refract) — compiles to a CSS @layer for deterministic precedence below unlayered app CSS. The SCSS twin of the CSS adapter's layer. |
import { createScssAdapter } from "@theme-registry/refract-scss"; const theme = createTheme(raw, { adapter: createScssAdapter({ indent: 2 }) }); theme.scss; // → $dt-colors-brand: …; .dt-components-buttons-primary { … }
Tokens lower to compile-time $variables (not :root custom properties), and rule-sets nest state / breakpoint overrides idiomatically with & and @media. Synthesized colour steps are baked as concrete values — the same class surface as CSS, from a Sass source:
// tokens → $variables (tonal steps synthesized at build) $dt-colors-brand: rgb(76, 110, 245); $dt-colors-brand-light: rgb(145, 167, 255); $dt-colors-brand-dark: rgb(59, 91, 219); $dt-colors-brand-text: rgb(255, 255, 255); // recipes → classes; state overrides nest with & .dt-components-buttons-primary { cursor: pointer; border: none; display: inline-flex; align-items: center; justify-content: center; gap: 8px; &:hover { transform: translateY(-1px); } }
JSON adapter createJsonAdapter
Emits refract's own format-neutral token document — tokens, rule-sets, keyframes and containers as data (recipes and composition survive, which a plain-tokens export can't represent). No CSS. Surface: theme.json. For standardized DTCG interchange instead, see DTCG interop.
| Option | Type | Default | Description |
|---|---|---|---|
| refs | "both" | "path" | "value" | "both" | How a derived token is represented — both = the ref/fn/arg and the resolved value; path = refs only; value = resolved values only. |
| indent | number | 2 | JSON indentation (spaces). |
import { createJsonAdapter } from "@theme-registry/refract-json"; const theme = createTheme(raw, { adapter: createJsonAdapter({ refs: "both" }) }); JSON.stringify(theme.json); // { breakpoints, containers, tokens, ruleSets, keyframes }
Every bucket is a flat map keyed by a Model address. A leaf mirrors the Model's Ref: in the default both mode a derived token carries its ref/fn/arg and the resolved value — so palette synthesis, states, responsive overrides and composition-by-reference all survive as data (things a tokens-only DTCG export cannot represent):
{
"tokens": {
"colors.brand": { "value": "rgb(76, 110, 245)" },
// a synthesized step keeps its derivation AND the resolved value
"colors.brand.lighter": {
"ref": "colors.brand.light", "fn": "lighten", "arg": 14,
"value": "rgb(160, 179, 255)"
}
},
"ruleSets": {
"components.buttons.primary": {
"kind": "recipe",
// composition survives as cross-subsystem token-path refs
"references": [
"colors:solid.brand", "typography:button.base",
"layout:padding.button", "effects:surface.focusable", "borders:edge.button"
],
"declarations": { "cursor": { "value": "pointer" } /* … */ },
"overrides": [ /* per-state / per-breakpoint deltas */ ]
}
}
}
styled-components adapter createStyledComponentsAdapter
Emits TypeScript/JavaScript modules, not a stylesheet — a literal theme object and tree-shakeable css recipes whose values read straight from the theme (no var()), a base-layer GlobalStyle (from the globals subsystem), and the types that make props.theme typed. Runtime surfaces: theme.theme · recipes · GlobalStyle · media · scheme.
| Option | Type | Default | Description |
|---|---|---|---|
| language | "ts" | "js" | "ts" | ts also emits theme.d.ts (augments DefaultTheme). |
| scheme | "media" | "attribute" | "both" | "media" | How appearance modes realize dark: prefers-color-scheme, a [data-theme] toggle, or both. |
| helpers | string[] | [] | ["color-math"] wires the lighten/darken/alpha import and hangs them on theme. |
| emit (target) | "single" | "split" | "single" | One module, or theme.ts / recipes.ts / global.ts split. |
| prefix | string | — | Accepted for parity with the CSS adapter's namer. SC identifiers carry no prefix (the literal theme object is the isolation boundary), so in v1 this does not alter emitted identifiers. |
| classPrefix | string | — | As prefix — accepted for parity, structural in v1. |
| naming | NamingOverrides | — | Shared naming-override type with the CSS adapter. className remaps a recipe's export identity (camelCased); variableName has no coherent target on the nested theme object and is accepted-but-structural (keys stay derived from the token address). |
The object is the indirection — every token a resolved literal, variants folded into their group as camelCase keys (primary → primaryText). Dark mode lives inside each recipe (a theme.scheme.dark / [data-theme] block), so it tree-shakes with the recipe and switches with a plain CSS recalc — one ThemeProvider, never swapped.
export const theme = { colors: { primary: "rgb(20, 126, 255)", primaryText: "rgb(255, 255, 255)" }, layout: { spacingMd: "16px", spacingLg: "24px" }, // variants fold into their group as camelCase media, scheme, } as const; // flat, tree-shakeable — values read from the theme, no var() export const colorsSolidPrimary = css` background: ${({ theme }) => theme.colors.primary}; color: ${({ theme }) => theme.colors.primaryText}; `; // composition = a css spread of the referenced siblings + own delta export const componentsButtonsPrimary = css` ${colorsSolidPrimary} ${typographyControlButton} ${layoutPaddingControlMedium} cursor: pointer; border: none; border-radius: 26px; `;
Conditional blocks — states, responsive & dark
A recipe is one css block that carries its own states, breakpoints and appearance modes as nested blocks — every value still read from the theme. Breakpoints come off theme.media.<bp>.{min,max,exact,between}; dark comes off theme.scheme.dark, reading the mode's values from theme.modes.dark.*. Because dark lives inside the recipe, it tree-shakes with it and switches as a pure CSS recalc — no global :root, no provider swap, no flash.
export const colorsSolidPrimary = css` background: ${({ theme }) => theme.colors.primary}; color: ${({ theme }) => theme.colors.primaryText}; ${({ theme }) => theme.media.md.min` /* responsive → @media (min-width: 768px) */ background: ${theme.colors.primaryDark}; `} &:hover { /* state */ background: ${({ theme }) => theme.colors.primaryDark}; } ${({ theme }) => theme.scheme.dark` /* dark — reads theme.modes.dark.* */ background: ${theme.modes.dark.colors.primary}; `} `;
The scheme option decides how the dark block is realized. "media" (default) wraps it in @media (prefers-color-scheme: dark) — follows the OS. "attribute" emits a [data-theme="dark"] & selector instead — a manual toggle wins. "both" emits both.
[data-theme="dark"] & { background: ${({ theme }) => theme.modes.dark.colors.primary}; }
Consuming the emitted modules — build-time
Run the adapter at build time (refract build or emit()) to write the modules, then import them. The theme object drives one ThemeProvider, set once; recipes drop straight into a styled block; refract itself never reaches the bundle. Ad-hoc styling reads literals off props.theme — and because those are real values, the opted-in color-math helpers run on them.
import styled, { ThemeProvider } from "styled-components"; import { theme, GlobalStyle, componentsButtonsPrimary } from "./theme"; const Button = styled.button`${componentsButtonsPrimary}`; // provider set once — dark mode is @media, no swap <ThemeProvider theme={theme}> <GlobalStyle /> <Button>Book flight</Button> </ThemeProvider>
Runtime — the same shapes, live
createTheme(raw, { adapter }) returns the same surface the emit writes — theme · recipes · GlobalStyle · media · scheme — but each recipe is lowered to a live css block on first access and cached (nothing pre-generated). No build step, and override() yields a fresh live surface — ideal for dynamic or white-label themes.
import styled, { ThemeProvider } from "styled-components"; import { createTheme } from "@theme-registry/refract"; import { createStyledComponentsAdapter } from "@theme-registry/refract-styled-components"; const t = createTheme(raw, { adapter: createStyledComponentsAdapter() }); const Button = styled.button`${t.recipes.components.buttons.primary}`; <ThemeProvider theme={t.theme}> {t.GlobalStyle && <t.GlobalStyle />} <Button>Book flight</Button> </ThemeProvider> // white-label: override() → a new live surface, no rebuild const brandB = t.override({ colors: { primary: { base: "#e8590c" } } });
Build-time — emit() / refract build | Runtime — createTheme | |
|---|---|---|
| Form | serialized .ts/.js modules | live css RuleSets, lazy + cached |
| refract at runtime | gone — zero dependency | present |
| Best for | ship, tree-shake, drop refract | dynamic / white-label, override(), no build step |
Writing an adapter
An adapter is the only place output format lives — the Model holds no CSS syntax. To target a new format (React Native, Compose, a design-token pipeline) you implement four required primitives; core supplies everything else. defineAdapter fills in the aggregators.
The adapter contract
The bundle a third-party adapter builds against is four exported types plus one subpath: you author an AdapterSpec whose bind() returns a BoundSpec, wrap it with defineAdapter to get a ThemeAdapter, and pull the shared naming/override machinery from @theme-registry/refract/adapter-kit (createNamer, resolveNaming, varNameFromPath, collision detection). AdapterSpec.version is the contract number external packages pin to. Every first-party adapter imports only these public entries — no deep imports into src/, a boundary a committed packaging test enforces — so "CSS is just another adapter" holds at the import level.
The four primitives
Core walks the Model — every subsystem, every (group, variant) — and calls these. You only describe how one unit renders in your format; join says how units combine.
| Method | Signature | Its job |
|---|---|---|
| recipeName | (sub, group, variant) => string | A rule-set's identity in your format — a CSS class, an RN style key. |
| renderRecipe | (sub, group, variant) => TUnit | Render one rule-set: base declarations + its state / responsive overrides. |
| renderVariables | (sub) => TUnit | Render one subsystem's tokens (its :root vars in CSS; an object in JSON). |
| join | (parts: TUnit[]) => TUnit | Combine units. CSS: parts.join("\n\n"); JSON: merge fragments; RN: merge objects. |
TUnit is your format's unit of output — string for CSS/SCSS, a document fragment for JSON, a style object for RN. Everything is generic over it.
The bind context
Your bind(model, ctx) runs once. ctx hands you the tools so you never re-derive theme state:
| ctx member | Type | What it gives you |
|---|---|---|
| media | MediaDescriptor | Breakpoints → an @media builder (already unit-resolved from createTheme's media). |
| containers | ContainerDescriptors | Per-named-container @container builders; empty when no containers. |
| resolve | (path) => Literal | Resolve a token path to a concrete literal — for inline / value-mode output. |
Optional hooks
| Hook | When to implement |
|---|---|
| allowedStates | The states your format understands (CSS: hover/disabled/…). Core validates recipe state: refs against it; absent ⇒ any state accepted. Declared at adapter level (normalization runs before bind). |
| extend(theme) | Attach runtime-only helpers to the theme root (the theme.media / theme.classes pattern). |
| emit(plan) | Build-time file output — switch on plan.type (single / split / subsystem / components) and throw for modes you don't support. |
| renderAll* overrides | Override the defaulted aggregators when your full document isn't a flat concatenation (JSON overrides these to merge buckets). |
Skeleton
import { defineAdapter } from "@theme-registry/refract"; export const createMyAdapter = (options = {}) => defineAdapter({ name: "my-format", version: 1, allowedStates: ["hover", "disabled"], // omit ⇒ any state accepted bind(model, ctx) { return { recipeName(sub, group, variant) { return `${sub}-${group}-${variant}`; }, renderRecipe(sub, group, variant) { /* … your format … */ }, renderVariables(sub) { /* … tokens for one subsystem … */ }, join(parts) { return parts.join("\n\n"); }, }; }, }); // then, exactly like every built-in adapter: const theme = createTheme(raw, { adapter: createMyAdapter() });
defineAdapter wraps your bind so the returned surface gains the defaulted renderAllRecipes / renderAllVariables / renderAll — pure Model walks over your four primitives. You write the format-specific 20%; core owns the other 80%.Adapter ecosystem
Adapters are the extension point, and they're packages, not plugins. Core (@theme-registry/refract) ships zero adapter implementations — even the first-party CSS adapter is its own package (@theme-registry/refract-css). Every adapter, first-party or community, is a standalone package that depends on refract for the contract. First-party and contributed adapters have the identical shape, so the ecosystem grows without touching core.
How adapters are packaged
| Kind | Where it lives |
|---|---|
| CSS (default) | Its own first-party package — @theme-registry/refract-css, depending on refract for the contract (same shape as every other adapter). Core ships no adapter. |
| Official adapters | Separate @theme-registry packages (SCSS, JSON, styled-components), each depending on refract and carrying its own peers. |
| Community adapters | Any scope — a standalone package that imports defineAdapter and the contract types from refract. Consumers wire it in their createTheme / config; nothing registers with core. |
There's no registry to publish to and no core change to make — an adapter is just a package that returns a ThemeAdapter. A consumer's createTheme(raw, { adapter: createYourAdapter() }) (or a build-config import) is the wiring. This is the same seam Rollup/Vite/PostCSS use for their plugins.
Quality checklist
A well-behaved adapter, so a theme is portable across it and the built-ins:
| ✔ | Expectation |
|---|---|
| contract | Built with defineAdapter — implements the four primitives; lets core fill the aggregators. |
| states | Declares allowedStates for the states your format supports (or omits it to accept any) — so recipe state: refs validate correctly. |
| emit modes | If it supports build-time emit, honors the modes it can and throws a clear error for the ones it can't — never emits silently-wrong output. |
| naming | Follows the token-path model — derive names from the path so output is predictable and consistent with the text adapters where it makes sense. |
| types | Ships its own .d.ts, including its options type — the consumer configures it typed, like CssAdapterOptions. |
| no core fork | Depends on refract for the contract; adds no runtime requirement on core internals beyond the public ThemeAdapter / AdapterSpec surface. |
Contributing
refract is early — the fastest ways to help: build an adapter for a format you need (React Native, Compose, a design-token pipeline) and share it; file precise error-message or docs issues; or contribute a preset. The Writing an adapter page is the technical starting point.
defineAdapter contract is the whole API. A default starter theme package is on the way — see Project status.Errors & troubleshooting
refract fails loudly at build — an invalid theme throws with a path-labelled message rather than emitting broken CSS. Every message below is verbatim from the library; each row is the cause and the fix. Messages are grouped by where you'll hit them.
RefractError with a machine-readable code (e.g. REFRACT_E_COLOR_INPUT, REFRACT_E_STEPS, REFRACT_E_NAMING) an agent or catch block can branch on without string-matching the message. Post-build reference validation collects all failures and throws them at once as REFRACT_E_VALIDATION — its .failures array lists every bad reference, so you fix them in one pass instead of one build at a time.Colours
The strictest subsystem: colours must be tonally derivable — so a var(--…), currentColor or transparent is rejected, while hex, [r,g,b], oklch(), hsl(), rgb() and named keywords all work.
| Message | Cause → fix |
|---|---|
Invalid colour "…". Author a colour as a hex string ("#4dabf7"), an [r, g, b] tuple, or a CSS colour — oklch(), hsl()/hsla(), rgb()/rgba(), or a named keyword (e.g. "rebeccapurple"). A var(--…) can't be tonally derived at build time. | A colour value (base / text / a literal variant) was a var(--…), currentColor, or transparent — the only inputs refract rejects, because none can be tonally derived at build time. Fix: use a hex string, an [r, g, b] tuple, or any resolvable CSS colour (oklch(), hsl(), rgb(), a keyword). |
Invalid colour tuple […]. Use an [r, g, b] tuple with 0–255 channels (alpha comes from an `alpha` variant, not the base). | A tuple wasn't three 0–255 channels. Fix: [76, 110, 245]; author opacity as an alpha variant, not a 4th channel. |
Unsupported hex length: "…". Use 3 or 6 digits. | A 4- or 8-digit hex. Fix: 3- or 6-digit hex; alpha via an alpha variant. |
colors.<name>.steps must be numbers in 0–1000 (e.g. [50, …, 950]); got … | A steps entry was non-numeric or outside 0–1000 (the ladder maps each label to L = (1000 − label)/10). Fix: use ladder labels in 0–1000; for named tonal steps omit steps (you get light/lighter/dark/darker) or declare them as variants. |
colors.<name>.variants.<v> needs one of darken / lighten / alpha / adjust. | A derivation-spec variant object with no fn. Fix: give it exactly one of { darken } / { lighten } / { alpha } / { adjust }. |
colors.<name>.variants.<v>.adjust.l must be an absolute OKLCH lightness in 0–100; got … | An adjust dial out of range (l outside 0–100, c negative, or a non-numeric dial). Fix: l 0–100, c ≥ 0 (a multiplier), h a number of degrees. |
colors.<name>.harmony: unknown scheme "…". Use one of complement, analogous, split-complement, triadic, tetradic. | A harmony value that isn't a built-in scheme (or an object naming more than one). Fix: use one scheme name, or the { scheme: [names] } object form. |
colors.<name> variant references unknown source "…". | A variant's ref points at a token that doesn't exist. Fix: reference the base or an existing variant/step. |
Cyclic colour variant derivation at colors.<name>.<v>. | Two variants derive from each other. Fix: break the cycle — anchor one on the base. |
Effects — shadows & transitions
| Message | Cause → fix |
|---|---|
… must be structured shadow layer(s) or "none" — raw CSS strings are not accepted (received …). | A shadow authored as a raw CSS string. Fix: use the structured form ({ offsetX, offsetY, blur, spread, color, inset }) or an array of layers, or "none". |
… has unknown shadow field "…" (allowed: offsetX, offsetY, blur, spread, color, inset). | A typo'd shadow key. Fix: use one of the six allowed fields. |
….color must be a "colors.*" token-path string. | A shadow's color was a literal, not a palette reference. Fix: reference a colour token (e.g. "colors.shadow.a20") — shadows theme via a translucent colour variant. |
… must be structured transition part(s) or "none" — raw CSS strings are not accepted (received …). | A transition authored as a raw CSS string. Fix: use the structured part form, an array, or "none". |
… has unknown transition field "…" (allowed: …). · ….property is required (the CSS property to transition). | A typo'd transition key, or a missing property. Fix: give each part a property plus duration/timingFunction/delay. |
….timingFunction must be a keyword or cubic-bezier(...) string. | An invalid easing. Fix: a keyword (ease, linear, …) or a cubic-bezier(...) string. |
Layout — scales & recipes
| Message | Cause → fix |
|---|---|
layout.<key>: declare only one of "ratio" (geometric) or "step" (linear) scale, not both. | Two scale curves on one property. Fix: pick ratio (geometric) or step (linear). |
layout.<key>: a geometric scale ("ratio") needs "steps" as an ordered name array, e.g. ["sm","md","lg"]. | A ratio scale without an ordered step-name array. Fix: supply steps: ["sm","md","lg"]. |
layout.<key>: a linear scale ("step") needs "steps" as a name→multiplier map, e.g. { sm:1, md:2, lg:3 }. | A step scale without a name→multiplier map. Fix: supply steps: { sm: 1, md: 2, lg: 3 }. |
layout.<key>: a responsive ramp entry needs a base scale — declare a top-level "ratio"/"step" (+ "steps") first. | A responsive ramp without a base scale to ramp from. Fix: declare the base ratio/step (+ steps) first. |
Unknown layout recipe property "…" in …. Known verbs: … | An unknown key in a layout recipe. Fix: use a known verb (see the Layout recipes). |
Globals
| Message | Cause → fix |
|---|---|
globals: unknown preset "…" — expected one of preflight, normalize, reset or false | An unrecognized globals.preset. Fix: use preflight, normalize, reset, or false. |
Recipes & composition
| Message | Cause → fix |
|---|---|
Recipe variant "…" is not defined in "<group>". | A composition references a recipe variant that doesn't exist. Fix: check the subsystem:group.variant reference against what the subsystem actually declares. |
Unknown recipe property "…" in … — expected a CSS property or a reserved key (variant, target, state, breakpoint, query, orientation, container, size). | A recipe declaration key that's neither a real CSS property nor a reserved recipe key — usually a typo (ref: where you meant a variant: swap, colr: for color:). Caught so it can't ship as a stray literal. Fix: use the CSS property, or the reserved key you meant. |
Cyclic recipe reference in "<group>": … | Recipes reference each other in a loop. Fix: break the cycle. |
components.<g>.<v>: css '<prop>' references unknown token '…' — check the token path, or use a bare string / number for a raw CSS value | A ref("…") in a component's css delta points at a token that doesn't exist. Fix: correct the path — or, if it's a raw CSS value, drop the ref() (a bare string in a css block is already a literal). |
Recipe state entry … references unknown state "…". | A states key the adapter doesn't know (the CSS adapter knows hover, disabled, …). Fix: use a state the target adapter declares in allowedStates. |
Responsive, containers & modes
| Message | Cause → fix |
|---|---|
Responsive recipe entry … references unknown breakpoint "…". / missing a "breakpoint" value | A responsive entry named a breakpoint that isn't in breakpoints, or omitted one. Fix: declare the breakpoint, or correct the name. |
Responsive entry for "…" cannot set both "variant" and "target". | variant (swap the active variant) and target (scope a rule to a variant) are opposites. Fix: pick one. |
Container recipe entry … references unknown container "…". | A container query named a container you didn't declare. Fix: declare it under containers. See Container queries. |
Container recipe entry … cannot use "<field>" — container queries respond to a container's inline size, not orientation/height/aspect-ratio. | An unsupported axis on a container query. Fix: query inline size only. |
Breakpoint "…" is not defined. | A media descriptor referenced an unknown breakpoint. Fix: add it to breakpoints. |
Appearance mode "…" overrides nothing. | A modes entry for a property that has no base to override. Fix: give the property a base value first. |
Tokens
| Message | Cause → fix |
|---|---|
Unknown token path "…". | theme.resolveToken(path) was called with a path that isn't in the Model. Fix: check the path against theme.tokens (a flat path → Ref map). |
Cyclic token reference: a -> b -> a | Aliases form a loop. Fix: break the alias cycle. |
Build-time (CLI)
| Message | Cause → fix |
|---|---|
No theme config found at … | The CLI couldn't locate theme.config.*. Fix: run refract init, or pass --config. See Build-time. |
"…" must export (default) a defineConfig({ raw, targets }) object. | The config didn't default-export a defineConfig(...). Fix: export default defineConfig({ raw, targets }). |
"…" has no targets to build. | An empty targets array. Fix: add at least one { adapter, outDir }. |
Adapter "…" does not implement emit(); it cannot build to disk. | A run-time-only adapter used at build time. Fix: use an adapter that implements emit(), or compile at run time. |
css adapter: emit mode '…' cannot be combined with the global inline option (inline bakes values, leaving no variables file) | split/subsystem needs a variables file, but inline bakes values away. Fix: drop inline for those modes. |
scss adapter: emit mode 'subsystem' is not supported yet · <adapter> adapter: emit mode '…' not yet implemented | That adapter doesn't support that emit mode. Fix: use single, or the CSS adapter for the richer modes. See the emit reference. |
colors.brand.text — …) so it points at the exact authored location. If you hit one not listed here, it's an internal invariant (a library bug) — please file it.Types
refract ships full TypeScript types — your editor is the authoritative, always-current shape. This page is the map: the public types you'll actually name, grouped by role, each pointing at where its shape is documented. (Import them from @theme-registry/refract.)
Authoring
| Type | What it is | Documented |
|---|---|---|
| RawTheme | The object you author — one optional key per subsystem. | RawTheme anatomy |
| ColorsRaw · TypographyRaw · EffectsRaw · BordersRaw · AnimationRaw · LayoutRaw · ComponentsRaw | The authored slice type for each subsystem. | the subsystem pages |
| ExtendedProperty | The { base, variants, responsive, modes } long form of a property. | RawTheme anatomy |
| ColorInput | A colour value — a hex string, an [r, g, b] tuple, or any resolvable CSS colour (oklch()/hsl()/rgb()/keyword). | Colors |
Theme & Model
| Type | What it is | Documented |
|---|---|---|
| Theme | What createTheme returns — model, tokens, resolveToken, override, plus the adapter's surface. | createTheme & the Theme |
| CreateThemeOptions | { adapter, media?, units?, baseFontSize? } — the second argument to createTheme. | createTheme & the Theme |
| ThemeModel | The format-neutral held state — the single source of truth. | createTheme & the Theme |
| Ref | A token leaf — { ref?, fn?, arg?, value? }; carries derivations. | RawTheme anatomy |
| Literal | A resolved value — string | number. | createTheme & the Theme |
| MediaConfig | { unit?, baseFontSize? } — units for query thresholds. | createTheme & the Theme |
| UnitsConfig | { default?, "<subsystem>"?, "<subsystem>.<property>"? } — length units for declaration values (§21); the build-time twin lives on defineConfig. | Length units |
Adapter contract
| Type | What it is | Documented |
|---|---|---|
| ThemeAdapter | The full adapter createTheme consumes. | Writing an adapter |
| AdapterSpec | What an adapter author writes — identity + bind. | Writing an adapter |
| BoundSpec | The render surface bind returns — the four primitives + optional hooks. | Writing an adapter |
| RenderContext | The bind context — { media, containers, resolve }. | Writing an adapter |
Adapter options
| Type | What it is | Documented |
|---|---|---|
| CssAdapterOptions | Options for createCssAdapter. | CSS adapter |
| ScssAdapterOptions | Options for createScssAdapter. | SCSS adapter |
| JsonAdapterOptions | Options for createJsonAdapter. | JSON adapter |
| StyledComponentsAdapterOptions | Options for createStyledComponentsAdapter. | styled-components adapter |
Build-time
| Type | What it is | Documented |
|---|---|---|
| ThemeConfig | The defineConfig({ raw, targets }) shape. | Build-time |
| EmitTarget | One output target — { adapter, outDir, emit?, guide? }. | Build-time |
| Emit | The emit-mode directive — single / split / subsystem / components. | Build-time |
| GuideConfig | Tunes the self-documenting llms.txt/manifest.json — { packageName?, llmsFile?, manifestFile? }. | Self-documenting output |
| PreviewConfig | Tunes the human-facing preview.html — { file?, title?, inline? }. | Human-facing preview |
Tooling & errors
| Type | What it is | Documented |
|---|---|---|
| RefractError | Every authoring/build error — carries a stable code (RefractErrorCode) and, for the aggregate, a failures list. | Errors |
| AuditResult · AuditOptions · PairingScore · WcagLevel | The contrast-audit surface returned by audit(theme). | Contrast audit |
| RefractDTCGExtension | The recipe round-trip payload under the com.theme-registry.refract DTCG extension. | DTCG interop |
@theme-registry/refract. Shapes live in the .d.ts, so they can't drift from the code the way a hand-copied table would.Performance & footprint
refract compiles a realistic theme in about a millisecond and the core + CSS adapter weigh ~36 KB gzipped — or nothing at all if you emit CSS at build time. The numbers below are measured and reproducible; treat them as order-of-magnitude, since they scale with your theme's size and your hardware.
npm run bench times createTheme + theme.css and override() (median of 400 runs in V8 / Node) against this site's richest preset — 177 recipe classes, 112 @media rules. npm run size reports the gzip footprint against a committed budget (gated in CI). Both live in scripts/. A typical app theme is smaller and faster.Runtime footprint
The browser bundle is core + the CSS adapter (the runtime graph the showcase itself loads):
| What ships | Size |
|---|---|
| Runtime bundle | core ~28 KB gzip · + CSS adapter ~8 KB gzip (measured, npm run size) |
| Build-time delivery | 0 KB — emit CSS to a file, <link> it, and refract never reaches the client (see Build-time) |
Compile cost
createTheme plus reading the full theme.css — the entire compile — on that heavy theme:
| Operation | Cost |
|---|---|
| createTheme() + theme.css | ~1.2 ms median — fast enough to run on page load or per request |
| override(delta) + .css | ~0.8 ms median (reuses the base — only the delta recompiles) |
override() is cheaper because it's a delta merge with structural sharing — only the changed subsystem slices re-normalize, everything else keeps its reference. That's the number behind the "delta merge, not a re-run" claim, and why spinning up many brand children off one base is close to free.
Emitted CSS size
Per preset — the stylesheet a browser actually downloads:
| Metric | Value |
|---|---|
| theme.css | ~27 KB raw · ~3.8 KB gzip |
| contents | 177 recipe classes · 112 @media rules |
Emitted CSS scales with recipes × conditions (states / breakpoints / containers), not with the number of tokens — tokens are just :root variables. A leaner recipe set emits proportionally less.
tokens · classes · KB — recomputed as you edit, so you can watch cost move with your theme.Browser support
The emitted CSS targets modern evergreen browsers — roughly Baseline early 2023 (Chrome/Edge 111+, Firefox 113+, Safari 16.4+). The features it leans on:
| Feature | Where it's used · floor |
|---|---|
:where() | zero-specificity recipe selectors — widely available since 2021 |
@layer | optional cascade layer for the emitted output (opt-in) — Chrome 99 / Firefox 97 / Safari 15.4 |
prefers-color-scheme | the modes dark/light blocks (media realization) — widely available since 2019 |
@container | only when you declare containers — Chrome 105 / Firefox 110 / Safari 16 |
oklch() | opt-in only (colorFormat: "oklch") — default output is rgb(), so the floor doesn't move unless you ask for it. Chrome 111 / Firefox 113 / Safari 15.4 |
Skip container queries and keep the default rgb() colour format and the floor drops further — the tokens themselves are plain custom properties, supported everywhere. Nothing here needs a polyfill or a build-time downlevel; the OKLCH colour math runs at build time and emits ordinary rgb(), so it costs the browser nothing.
Output stability
Emitted output is a stable contract across patch and minor releases: the same theme compiles to byte-identical CSS — same --<prefix>-… variable names, same class names, same rule order — so you can snapshot-test the emitted stylesheet and bind downstream code to the names. A change to an emitted name or the output structure is a breaking (major) change, called out in the release notes; opt-in features are byte-identical when off. This complements the token-path stability guarantee — the format-neutral identity (the token path) and the rendered name are both treated as public API.
How refract compares
refract is a theme compiler with a format-neutral Model — not a utility framework and not a CSS-in-JS runtime. It sits closest to DTCG token transformers (one source, many outputs), but it also models recipes and stores a reference graph — so override() is a delta-merge and diffTheme can show a change's blast radius before you apply it. Two questions place any tool: what does it commit your output to, and does it model the design system (recipes) or just the token values.
| Tool | Great at | How refract differs |
|---|---|---|
| Tailwind CSS v4 | utility-first styling; CSS-first @theme config; superb iteration DX | refract isn't a utility framework — it emits your named tokens + recipes in any format, not utility classes. They compose: run refract's namespaced output alongside Tailwind (FAQ). |
| Panda CSS | build-time, type-safe recipes → atomic CSS + generated JS | a similar recipe idea, but Panda commits you to its own CSS/JS output and build step. refract's Model is format-neutral (one theme → CSS / styled-components / SCSS / JSON, pluggable) and carries a diffable reference graph. |
| vanilla-extract | zero-runtime, TypeScript-authored static CSS | CSS output only — no multi-format target, no DTCG interop, and no synthesized tonal ramps or override() delta-merge off a base. |
| Style Dictionary | transforms DTCG tokens into many platform formats | closest in spirit (one source, many outputs) — but it's a value-level transformer: no recipes/composition, and no reference graph, so no plan-then-apply diff. |
| Terrazzo (ex-Cobalt) | DTCG token tooling with a plugin ecosystem → many formats | same category as Style Dictionary; refract adds the design-system layer (recipes + composition) and the stored { ref, fn } graph behind override() / diffTheme. |
The line that separates refract from every tool above: it stores a reference graph, not frozen values — every synthesized rung and variant is held as { ref, fn, arg } that points at its source. That's what makes override() re-derive a whole ramp from one base, and it's what lets diffTheme answer a question a token file can't: what will this change do — before I make it. Pair that with a format-neutral Model (the same theme emits CSS today and styled-components tomorrow) and an agent-native surface (an MCP server + installable skills), and the niche is specific: a theme compiler you don't get locked into. It doesn't replace your utility framework or your CSS-in-JS runtime — it owns the tokens-and-recipes layer beneath them.
FAQ
Common questions about how — and why — refract works the way it does.
Why does core require an adapter — why no default?
Because the moment core has a default output, the Model isn't format-neutral any more — it quietly assumes CSS. Requiring an adapter keeps core holding zero output syntax: the same Model lowers to CSS, SCSS, JSON or styled-components with nothing baked in. It's one extra argument in exchange for the whole multi-format thesis. See createTheme.
Which colour inputs does refract accept, and why is var(--…) rejected?
A colour value has to be tonally derivable — refract synthesizes light/dark/numeric steps from it. Anything it can parse to concrete channels works: a hex string, an [r, g, b] tuple, oklch(), hsl()/hsla(), rgb()/rgba(), or a named keyword like "rebeccapurple" — all normalized to a canonical rgb() at build. The only inputs refused are var(--…), currentColor and transparent: a var() isn't known at build time, and the other two have no fixed channels to derive a ramp from. (Recipe declarations like color: "white" are unaffected — those are plain CSS.) See Colors.
Why OKLCH colour math?
Lightness derivations (lighten / darken / the numeric ladder) run in OKLCH, a perceptual space: equal lightness moves look even to the eye, and one lightness reads the same across hues — so blue.500 and red.500 land at the same lightness for free, and ramps don't muddy or blow out the way fixed-percentage RGB steps do. The maths is self-contained (Björn Ottosson's public-domain matrices, no dependency) and round-trips through rgb only at the boundary, so the Model still stores each colour as a canonical rgb() / rgba() string and the runtime graph carries no colour-space library. (DTCG export converts to hex, the DTCG convention.) Colours out of the sRGB gamut are handled by holding lightness and hue and easing chroma down — never by clipping channels, which would shift both.
Can I use refract alongside Tailwind (or any other CSS)?
Yes. refract emits its own namespaced surface — --<prefix>-… variables and .<prefix>-… classes (default dt, configurable) — so nothing collides with utilities or hand-written CSS. It contributes a stylesheet and class names; it doesn't take over the page. See Token paths & naming.
Is the run-time compile safe for user-supplied themes?
createTheme runs no eval — it consumes a plain object (the Playground uses JSON.parse, not code execution), so parsing an untrusted theme doesn't run untrusted code. The one thing to remember: injecting theme.css puts the theme author's values into a stylesheet, so treat an untrusted theme the way you'd treat untrusted CSS (it can style, not script). For fully-untrusted input, validate the shape and inject via a trusted <style>, not <link> to arbitrary URLs.
The trust boundary is your input. refract validates structure (colour parseability, token refs, units), not the content of literal declarations: a recipe or component css value (cursor: "pointer", content: "…") passes through to the output verbatim. So compiled output is only as trusted as the theme it came from — if you compile agent- or user-authored themes and serve the result, sanitize the literal css values first (reject stray }, </style>, or url()) exactly as you would any untrusted CSS. Token values (colours, lengths) are always re-serialized to a canonical form, so the passthrough surface is the literal css block only.
Why is DTCG an interop boundary and not an adapter?
An adapter is a rendering of the Model into an output format, and they're plural and swappable. DTCG is data interchange — a single, universal, property-token-only bridge to the design-token ecosystem, tightly coupled to the raw shape and token map. It doesn't render the Model; it moves tokens in and out. So it lives as fromDTCG / toDTCG at the ./dtcg subpath, not as a ThemeAdapter. See DTCG interop.
Project status
refract is beta. Here's what that means concretely — what you can build on from day one, what's still moving, and what's next.
Where refract comes from
refract didn't start as a library. It started as the infrastructure behind a flagship site and ~130 sister sites that shared one administration and one foundation — each differing in little more than its colour palette and logo, none allowed to drift in look and feel. Frontends were Next.js + styled-components on a shared CMS-backed admin, templates loaded lazily, one style-guide driving them all. That pattern — one base theme, many brands, override only what's yours — went on to run a multi-brand media group, a pan-European NGO network, and a multi-identity civic institution.
Those estates ran on a UI-focused theme registry (previously under an earlier npm scope, now consolidating under @theme-registry): a main theme plus child themes that override whatever they need. What it never standardized were the foundations — layout (grids, columns, spacing), the media model, and colour/type synthesis, solved well but ad-hoc. refract is those foundations, taken from what worked, made solid and format-neutral, and made to show their work (diffTheme blast radius, WCAG audit) before anything ships.
Tested & gated
Beta doesn't mean untested. refract ships with a real safety net: 760 tests across 66 files (unit · golden-output · integration), and every push to main runs five gates in CI — a green run is a precondition for merge and for the docs deploy. The counts and budgets on this page are drift-gated against a live compile, so they can't quietly go stale.
| CI gate | What every push must pass |
|---|---|
| Build | pnpm -r build — every package compiles |
| Typecheck | pnpm -r typecheck — no tsc errors across the monorepo |
| Test | pnpm -r test — the full 760-case suite |
| Docs drift | docs:validate — every documented error message, emitted name, perf figure, and the counts above are checked against the live library |
| Size budget | pnpm run size — the gzip footprint stays under a committed ceiling (below) |
Size budget gzip · CI-enforced
Each published surface carries a committed gzip ceiling in size-budget.json; a regression that pushes past it fails the build. The runtime you actually ship to a browser is core + one adapter — everything else is build-time only, or nothing at all if you emit CSS ahead of time.
| Package | Budget (gzip) |
|---|---|
| @theme-registry/refract runtime core | ≤ 30 KB |
| @theme-registry/refract-css | ≤ 9 KB |
| @theme-registry/refract-styled-components | ≤ 6.5 KB |
| @theme-registry/refract/dtcg interop subpath | ≤ 4.5 KB |
| @theme-registry/refract-scss | ≤ 4 KB |
| @theme-registry/refract-json | ≤ 3 KB |
npm run bench (compile timings) and npm run size (gzip vs budget), both in scripts/.Stability
Two statuses. Stable: breaking changes are deliberate, announced events — safe to build on. Experimental: the shape may still change; pin your version and expect some churn.
| Surface | Kind | Status | Docs |
|---|---|---|---|
| RawThemethe input format | Core | Stable | RawTheme anatomy |
| Theme runtimecreateTheme · tokens · resolveToken · override | Core | Stable | createTheme & Theme |
| Adapter contractdefineAdapter · AdapterSpec · BoundSpec · RenderContext | Core | Stable | Writing an adapter |
| Token pathsthe format-neutral path per token — adapters render it into names | Core | Stable | Token paths & naming |
| CSS adapteroutputs a CSS stylesheet | Adapter | Stable | CSS adapter |
| SCSS adapteroutputs Sass source | Adapter | Experimental | SCSS adapter |
| JSON adapteroutputs a JSON token document | Adapter | Experimental | JSON adapter |
| styled-components adapteroutputs TS/JS theme modules | Adapter | Stable | SC adapter |
| DTCG interopDTCG token import + export | Interop | Stable | DTCG interop |
| MCP serverlive theme queries for AI agents | Tooling | Stable | MCP server |
In short: core, the CSS and styled-components adapters, DTCG interop, and the MCP server are stable at public release — the authoring format, the theme runtime, the adapter contract, the emitted names, and the agent query surface. The SCSS and JSON adapters are still settling.
Versioning
refract and its adapters version in lockstep: all six packages share one version through the whole 0.x line — a single Changesets fixed group that publishes together — so a matched set is always safe to install. Stability tiers are signalled by an npm dist-tag, not by divergent versions: the stable packages ride latest, the SCSS and JSON adapters are reachable via the experimental tag. Pin exact versions through 0.x; at 1.0 the fixed group splits into independent lines (core on its own 1.x). Full policy: RELEASING.md. One guarantee already holds: token paths are stable identifiers — a path won't change or vanish within a minor/patch release, so agents and DTCG documents can bind to it (see Token paths & naming).
0.x: token paths and emitted output (same theme → byte-identical CSS). Still moving: the SCSS/JSON adapter shapes and the “What's next” items below. At 1.0 the public contract — RawTheme grammar, REFRACT_E_* codes, manifest schema, adapter contract — becomes semver-stable and the lockstep group splits. Every release is recorded in the CHANGELOG; the full policy is in RELEASING.md.What's next
- A default starter theme. A complete, ready-to-use design system in a separate
@theme-registrypackage — a full styleguide you don't have to build, but format-agnostic: export it through any available adapter (CSS · SCSS · JSON · styled-components), or through one you write yourself. It also becomes the shared basis for this site's examples and playground presets. - Contrast audit shipped — the opt-in
refract auditcommand (and programmaticaudit(theme)) scores every palettebase↔textand recipe fg/bg pairing against WCAG 2 (with an advisory APCA Lc). It reports by default;--strictfails the run. See Contrast audit. - Logical properties / RTL planned — a
logical: trueoutput option (padding-inline,border-inline-start,side: "start" | "end") so themes mirror for right-to-left for free. - Compound variants planned — a multi-axis matcher (size × tone × shape) so combinations resolve automatically instead of by hand-enumeration.
- Multi-source external tokens planned — borrowing from two prefixed parent systems in one theme via named sources (single-source external ships today).