Architecture
A Recursica theme is stored in three JSON files. The tokens file holds the actual values, such as the hex code for each color. The brand file assigns those values to roles, such as the color for links. The UI kit file assigns roles to components, such as the color of a button’s label. Each value is written down only once, in the tokens file, so changing it there changes it everywhere. The files use the W3C Design Tokens Community Group (DTCG) format, so any tool that reads that format can use them. They also export to CSS variables for use in code.
The three token files
Every Theme Forge export has the same three files. Each one builds on the one before it.
1. recursica_tokens.json: the raw values
This file holds raw values with no meaning attached, called primitives: color scales, sizes, font families, and opacities. A primitive knows it is the color #e9e9e9, but not that it’s used for borders.
tokens/colors/scale-01/100 → #e9e9e9
tokens/sizes/0-5x → 4px
Nothing in the system uses a primitive directly. Primitives exist so that every other value has one place to come from.
2. recursica_brand.json: what each value is for
This file gives each value a job by pointing it at a primitive. The brand file decides which color means “clickable”, which color is body text, and what a heading looks like. It holds the light and dark themes, the four layers, the type scale, spacing, and fonts.
brand/themes/light/layers/layer-0/elements/text/warning
→ {tokens.colors.scale-04.700}
brand/dimensions/icons/sm
→ {tokens.sizes.2x}
Light and dark both live in this one file and use the same names. Only the primitives they point at are different, which is why the two themes never drift apart.
3. recursica_ui-kit.json: the components
This file lists every property of every component, and points each one at a value in the brand file. It covers 55 components, with all of their variants, sizes, and states.
ui-kit/components/button/variants/styles/solid/properties/elevation
→ {brand.elevations.elevation-0}
A component never contains a color or a pixel value. A button’s background, for example, names the brand role “interactive”, and the brand file names the exact color from the tokens file.
One-way references
ui-kit → brand → tokens
Values always point down this chain, from components to brand to tokens. They never point back up or sideways. That one rule is what makes the system work:
- Changing a theme is quick. Change one value in the tokens file, and every brand role and component that uses it changes too, with no edits to any component.
- Problems are easy to find. If a button is the wrong color, the cause is in one of three places: the component, the brand value, or the primitive. Following the chain shows which one.
- Meanings stay the same through a redesign. The “clickable” color is still the clickable color after the brand color changes.
The DTCG format
The file format is what lets a Recursica theme work in other tools.
- It’s an open standard from a W3C community group, not a format one company owns. Any tool that reads DTCG can read a Recursica theme.
- Every value has a type, like
$type: coloror$type: dimension, so a tool knows what a value is without guessing from its name. - Pointing one value at another is built in. The
{group.path.name}syntax is part of the standard, so the chain of references stays intact through export and import. - Changes are easy to review. Themes are JSON files in a code repository, so a change shows up in a pull request like any other code.
Theme Forge exports
Theme Forge exports the three JSON files plus a CSS file with one variable for each token. Each variable is named after its path:
--recursica_tokens_colors_scale-01_100
--recursica_brand_layer_0_elements_interactive_color
--recursica_ui-kit_components_button_variants_content_label_variants_sizes_default_properties_border-radius
The component adapters only read these CSS variables. That’s why a new export can restyle a whole app without changing any component code.
CSS variable groups
The CSS file groups its variables into blocks. The block a variable is in shows what the variable depends on.
Variables that never change: :root
These don’t depend on the theme or the layer: the primitives, plus brand values that are the same in light and dark (spacing, corner radius, the type scale, and fonts). They work anywhere on the page.
:root {
--recursica_tokens_colors_scale-01_100: #e9e9e9;
--recursica_brand_dimensions_border-radii_xl: 28px;
}
Variables that change with light and dark mode: [data-recursica-theme]
These depend on whether the page is in light or dark mode. Set the attribute once on <html> and the whole page uses the right values. Layer 0 is the default, so a page that never sets a layer already works.
Variables that change with the layer: [data-recursica-layer]
These depend on how deep an element sits in the layers, like a card on the page or a menu over a card. Set data-recursica-layer on a container, and everything inside it uses that layer’s values. Each layer sets values for every component, not just its own background and text. That’s why the same button looks different in a modal than on the page.
General names and specific names
Most values have two names. Knowing which one to use is how the whole system works:
- General names, like
--recursica_brand_layer_1_properties_surface, don’t include the theme or layer. Components should use these. The browser fills in the right value from the elements around them. - Specific names, like
--recursica_brand_themes_light_layers_layer-1_properties_surface, include the theme and layer. They exist so the general names have something to point at. If a component uses one, it’s stuck in that theme and won’t change with light or dark mode.
So the rule is: don’t write CSS selectors that check data-recursica-theme or data-recursica-layer. Set the attributes, use the general names, and let the browser do the rest.
Watch out for one thing: the general names for layers 1 to 3 only exist inside those layers. An element that uses layer 1 values has to be inside a container with data-recursica-layer="1", or the values come out empty. Layer 0’s general names are set for the whole page, which is why the default always works.