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: #1B19FF
  • color-gray-100: #F4F4F5
  • radius-md: 0.5rem
  • space-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-accentcolor-blue-500
  • bg-basecolor-white
  • fg-defaultcolor-gray-900
  • border-defaultcolor-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).

  • --primarybg-accent
  • --backgroundbg-base
  • --foregroundfg-default
  • --borderborder-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:

  1. 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.
  2. The same primitive can play multiple roles. color-blue-500 is bg-accent, but it might also be the border-focus-ring and the fg-link. Naming all three semantically keeps them independent — if you later want the focus ring to be color-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-brand once. Point bg-accent, border-focus, fg-link, and bg-cta-primary at 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 --primary directly to color-blue-500 works today and breaks the day you want to test a different accent without touching the palette.

Anti-patterns

  • Naming primitives by intent. color-primary: #1B19FF reads cleanly but locks you in. If the brand color is later red, you either rename the primitive (breaking every reference) or live with a color-primary that'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 darkReferences empty. Only override what differs.

Where this lives in code