Three-tier tokens
Note: Colour is now organised as colour families, not as flat semantic + component tiers. This page still explains primitive → semantic resolution (which families build on) and typography; the component colour tier described below has been replaced by families.
Tostada's token system has tiers. Each tier has a job. Mixing them — naming a primitive after its intent, or wiring a component variable directly to a primitive — works, but loses leverage.
The three tiers
Tier 1 — Primitives
Raw values, named after what they look like.
color-blue-500: #1B19FFcolor-gray-100: #F4F4F5radius-md: 0.5remspace-4: 16px
Primitives are the palette. They have no opinion about how they're used. Name them so a new teammate can guess what the value looks like from the name.
Tier 2 — Semantic tokens
Aliases to primitives, named after intent.
bg-accent→color-blue-500bg-base→color-whitefg-default→color-gray-900border-default→color-gray-200
Semantic tokens describe roles in the UI. They don't tell you what they look like — they tell you what they're for. Two semantics can point to the same primitive without confusion: bg-accent and bg-button-primary might both resolve to color-blue-500 today and diverge tomorrow.
Tier 3 — component bindings
Bind a component CSS variable to a semantic (or directly to a primitive).
--primary→bg-accent--background→bg-base--foreground→fg-default--border→border-default
the component layer provides ~30 CSS variables. Tostada makes each of them editable. Once the binding is set, every component in the preview iframe reflects your tokens automatically.
Why three tiers and not two
Two tiers (primitive + component) would work, but you lose two big benefits:
- Brand updates propagate cleanly. When the brand color changes, you change one primitive. Every semantic that points at it updates. Every component variable bound to those semantics updates. No find-and-replace.
- The same primitive can play multiple roles.
color-blue-500isbg-accent, but it might also be theborder-focus-ringand thefg-link. Naming all three semantically keeps them independent — if you later want the focus ring to becolor-blue-300, you change one binding.
Light + dark resolution
Every semantic and every component binding has TWO references: light and (optional) dark.
shadcn binding (light)
→ semantic.references
→ primitive.value
shadcn binding (dark, if present)
→ semantic.darkReferences (or .references if no dark override)
→ primitive.value
In dark mode, Tostada resolves through darkReferences first; if not set, it falls back to the light chain. This means you only need to override what actually differs between modes — typically a handful of semantics.
See light and dark for the full resolution algorithm.
Patterns
- One brand color, many semantics. Define
color-brandonce. Pointbg-accent,border-focus,fg-link, andbg-cta-primaryat it. Now every "branded" surface moves together. - Surface scale before content scale. Build out the bg / fg / border / accent semantics before you start naming
fg-warning-text-on-warning-bg. - Don't skip the semantic tier. Binding
--primarydirectly tocolor-blue-500works today and breaks the day you want to test a different accent without touching the palette.
Anti-patterns
- Naming primitives by intent.
color-primary: #1B19FFreads cleanly but locks you in. If the brand color is later red, you either rename the primitive (breaking every reference) or live with acolor-primarythat's red. - Binding component directly to primitives. Same problem with one extra layer of churn.
- Forking dark instead of overriding. If 90% of your dark tokens are the same as light, leave
darkReferencesempty. Only override what differs.
Where this lives in code
- Token data:
src/store/index.ts(Zustand store) + localStorage keytostada-library - CSS emission:
src/lib/docs.ts—generateTokensCss - Type definitions:
src/tokens/types.ts