refract v0.1 · beta
Core concepts / Token paths & naming

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.

Token paths are stable identifiers. A path (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:

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

the path is the neutral identity
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:

AdapterProperty colors.brand.darkRule-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:

OptionDefaultControls
prefix"dt"The leading segment of every variable--<prefix>-<path>.
classPrefix= prefixThe leading segment of every class.<classPrefix>-<path>. Falls back to prefix.
theme.ts
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>.

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

FormatterAddressCovers
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.
theme.ts
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,
  },
}) });
emitted — names flip consistently at every site
--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.

These guarantees are tested, not just asserted. Naming is deterministic (a pure function of the address) and collision-free on both the default and custom-override paths — covered by 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.

Want precedence guaranteed regardless of load order? Reach for a cascade @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.
Technical documentation for @theme-registry/refract · all output is compiled client-side by the real library (CSS adapter). The chrome is theme-aware; the render panes carry each preset's own world. · MIT licensed.