---
title: "Token architecture"
description: "The three tiers a token can live in, the one direction references are allowed to point, and why a component may never reach past the semantic layer."
url: "https://opsinjs.pensievelabs.org/foundations/token-architecture"
source: "https://opsinjs.pensievelabs.org/foundations/token-architecture.md"
section: "Foundations"
kind: "foundation"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["three tiers", "primitive tokens", "semantic tokens", "component tokens"]
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> Nothing is missing from this page. The data simply does not live in
> the prose.

<PageTemplate kind="foundation" />

## Overview [#overview]

A design token is only useful if you can change it without reading every place it
is used. That property does not come from having tokens; it comes from having
*tiers*, and from a rule about which tier is allowed to reference which.

opsinjs has three: **primitives**, which are raw values with no opinion;
**semantic roles**, which name a job; and **component tokens**, which are the
handful of decisions a single component needs and nobody else should share. The
one rule that makes the structure hold is that references point in exactly one
direction and never skip a tier or turn around. The direction runs from
component to semantic, and from semantic to primitive.

This is often confused with the Tailwind `@theme` layer, which is a different
concern: `@theme` decides which tokens become utility classes. Some of ours do,
most do not, and the mapping is documented in
[Theming → Tailwind v4](../theming/tailwind-v4.mdx).

## How it works [#how-it-works]

**Tier 1 primitives.** A ramp step, a duration in milliseconds, a radius in
rem. Named for what it *is*: a lightness step on a hue ramp, not a purpose. A
primitive never appears in a component and never appears in an example on this
site. It exists so that the semantic layer has something to point at, and so that
regenerating a ramp changes one file rather than forty.

**Tier 2 semantic roles.** Named for the *job*, and this is the tier you
actually work with. `--opsin-status-urgent-line` is a semantic role: it says
"this is the line weight of the highest status level" and it says nothing about
which hue currently satisfies that. Roles are the stable public surface. They are
covered by the [versioning policy](../project/versioning-policy.mdx) in the same
way as the JavaScript API, because a product that styles against a custom
property has taken a dependency on its name.

**Tier 3 component tokens.** Scoped to one component, and only created when a
decision is genuinely local, such as the inset of a range bar's marker, which no
other component has an opinion about. A component token always resolves to a
semantic role by default, so overriding one is a local adjustment and never a
fork. These are listed per selector by `<CssVariablesTable>` on each component
page rather than dumped globally, because the point of a component token is that
you can find it without reading the whole system.

The reference direction is the whole architecture:

<FlowDiagram>
  {`flowchart LR
    C["Component token
    --range-bar-marker-inset"] --> S["Semantic role
    --opsin-status-urgent-line"]
    S --> P["Primitive
    ramp step, raw duration"]
    C -.->|banned| P`}
</FlowDiagram>

The dotted edge is the failure this page exists to prevent. A component that
reaches straight to a primitive still renders, still looks right, and quietly
opts itself out of every theme, every preset and every contrast measurement.
Nothing tells you until somebody derives a theme from their brand colour and one
component stays the old hue.

## Using it [#using-it]

**Choosing a tier.** Ask who else could plausibly want this decision. If the
answer is "any component showing a status", it is a semantic role. If it is "only
this one, and only because of its geometry", it is a component token. If your
answer is "nobody, it is just the value I need", you have found a primitive and
you should not be using it directly.

**Adding a role.** New semantic roles are a permanent commitment. See
[Adding your own tokens](../theming/adding-your-own-tokens.mdx) for the extension
recipe that survives an upgrade, and
[Contributing tokens](../handbook/contributing/contributing-tokens.mdx) for the
migration obligation a change to an existing role creates.

**The three mistakes worth naming.**

<DoDont>
  <DoDont.Do>
    `background: var(--opsin-status-watch-surface)` is a role, so a preset can
    move it, `pnpm contrast` can measure it, and the CVD audit can see it.
  </DoDont.Do>

  <DoDont.Dont>
    `background: oklch(0.966 0.042 85)` is the same colour today, invisible to
    every tool that keeps the system honest, and wrong the moment anyone themes
    the product. Use the role.
  </DoDont.Dont>
</DoDont>

The second mistake is an alias chain: a role that points at another role that
points at a third. Two hops is a smell and three is a bug, because nobody can
predict what an override does. The third is a role named after its appearance.
That means `--opsin-amber-surface` rather than `--opsin-status-watch-surface`.
Appearance names are how a system ends up with a green "amber" token after a
redesign.

## Tokens [#tokens]

The generated list of every token, its tier, what it controls and what consumes
it, is at [Reference → Tokens](../reference/generated/tokens.mdx); the raw
custom-property names, for consumers that do not use Tailwind, are at
[Reference → CSS variables](../reference/generated/css-variables.mdx). Both are
produced by `scripts/build-tokens.mts` from `tokens/*.json`, and both are
current. This page owns no family of its own, so it prints no table here rather
than a slice of somebody else's.

## Accessibility impact [#accessibility-impact]

Tiering is what makes accessibility measurable rather than asserted. Every
contrast figure this site publishes is a measurement of a *pair of semantic
roles*. The pair is a foreground role against a background role, and the
measurement is only possible because those pairs have stable names. A component
that inlines a value is excluded from `pnpm contrast` silently: no error, no
failure, just a surface nobody has checked.

The same applies to the user preferences, and the current state of them is worth
knowing precisely. `prefers-reduced-transparency` is answered in the token layer
exactly as this page argues it should be: one media query in
`app/tokens.generated.css` redefines the tint, alpha, blur and saturation of all
six material rungs, so every surface responds and no component has to know.
`prefers-reduced-motion` is answered the same way but only for the spring
easings and durations; the plain duration steps are not redefined, so a
transition timed with one of those still runs at full length. `prefers-contrast`
has no block in the token layer at all. The one place it is answered today is
`Surface`, in that component's own utilities, which is precisely the pattern
this section argues against. The gap is tracked on
[Increased contrast](../accessibility/increased-contrast.mdx). A component
holding its own duration or its own translucency does not respond to any of
these, and the person who set the preference gets no signal that it was ignored.

## Related [#related]

* [Theming → Adding your own tokens](../theming/adding-your-own-tokens.mdx) is
  the other half of the signpost: how to extend the tiers in your own build.
* [Handbook → Styling](../handbook/styling.mdx) covers the four hooks a
  component exposes, and which tier each of them addresses.
* [Colour → Colour roles](./colour/colour-roles.mdx) has the largest set of
  semantic roles in the system, and the best worked example of tier 2.
