---
title: "Theming & tokens"
description: "The three tiers of the opsinjs token system, which tier you are allowed to change, and what the system refuses to let you change at all."
url: "https://opsinjs.pensievelabs.org/theming"
source: "https://opsinjs.pensievelabs.org/theming.md"
section: "Theming & tokens"
kind: "guide"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["theme", "retheme", "token tiers", "design 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="guide" />

## Overview [#overview]

Three pillars of this site talk about tokens and they do not overlap.
&#x2A;*Foundations is what a token means. Handbook and Theming are how you change it.
Reference is the generated list of every one.** If you want to know why status
colour is high-chroma and category colour is not, you are in the wrong pillar.
That is [Foundations](../foundations/index.mdx). This pillar assumes you have
already accepted the model and now have a brand to apply.

opsinjs is not a themeable component kit with a health skin on top. It is a
health system that happens to be themeable, and the difference shows up exactly
here: some of the token surface is yours, some of it is negotiable, and a small
part of it is closed because changing it changes what a screen asserts about
somebody's body. This page draws those three lines and then hands you off to the
page that does the work.

Everything below is real today. Tokens shipped before components did, and that
was a deliberate ordering decision, recorded in
[Tokens before components](../project/decisions/index.mdx). A theme you build
against the tokens is correct against the components that have since landed.

## The three tiers [#the-three-tiers]

Every custom property in opsinjs sits in exactly one of three tiers. The tier
tells you who owns the value, what may reference it, and what happens when you
change it.

### Tier 1 ramps [#tier-1-ramps]

Names look like `--opsin-status-watch-line` and `--opsin-category-sleep-accent`.

These are the measured colour values: six category ramps, four status ramps, the
neutral ramp, the six-rung material ladder, the motion springs, the radius and
space scales. They are generated into `app/tokens.generated.css` by
`scripts/build-tokens.mts` from the JSON under `tokens/`, and every pair in them
has a machine-measured contrast figure attached. Nothing in product code should
reference a tier-1 name directly. A component that reads
`--opsin-category-heart-accent` has hard-coded the answer to a question the
role tier exists to answer.

You change tier 1 by editing `tokens/*.json` and regenerating, which in practice
means you are maintaining a fork. See
[Category palettes](./category-palettes.mdx) and
[Status palettes](./status-palettes.mdx) for what that costs on each axis. The
answer is very different for the two.

### Tier 2 roles [#tier-2-roles]

Names look like `--background`, `--foreground`, `--card`, `--muted`, `--primary`,
`--border`, `--ring`, `--radius`, `--spacing`.

This is the tier you retheme, and it is deliberately the shadcn token
convention rather than a private one: a surface token names what a surface is
*for*, and its `-foreground` partner names the text and icons that sit on it.
Anything you already know about theming a shadcn project applies unchanged here.
Roles are also deliberately fewer than shadcn's default set in the product
theme. A health screen that needs nine background roles has a layout problem,
not a token problem.

Roles are exposed to Tailwind through `@theme inline`, so `bg-card`,
`text-muted-foreground` and `rounded-lg` resolve through your values without a
rebuild of anything else. [Tailwind v4](./tailwind-v4.mdx) explains why `inline`
is load-bearing and what silently breaks without it.

### Tier 3 component variables [#tier-3-component-variables]

Names look like `--opsin-range-bar-track` and are declared on a component's own
selector, never globally.

A component publishes the handful of variables that let you restyle one of its
parts without forking it, and the page of a component that declares any
documents them in a `<CssVariablesTable>` scoped per selector rather than as a
global dump. Now that
components have shipped, the tier-3 variables a given component exposes live on
that component's page rather than in one global list here, so this pillar points
you at the component instead of restating them.

<Callout>
  The rule that follows from the tiers: &#x2A;*product code reads tier 2 and tier 3.
  Tier 1 is an implementation detail of the generator.** A lint rule that enforces
  it is specified in [Handbook tooling](../handbook/tooling/index.mdx).
</Callout>

## Where you may intervene [#where-you-may-intervene]

| You want to                                   | Do this                                                                                          | Tier           |
| --------------------------------------------- | ------------------------------------------------------------------------------------------------ | -------------- |
| Apply your brand colour                       | Run the [theme generator](./theme-generator.mdx), paste the CSS it emits                         | 2              |
| Ship a second brand for a white-label build   | Publish a [preset code](./presets.mdx) per brand                                                 | 2              |
| Change corner radius, spacing, or the UI font | Set `--radius`, `--spacing`, `--font-sans`                                                       | 2              |
| Add a token the system does not have          | Follow [Adding your own tokens](./adding-your-own-tokens.mdx)                                    | 2              |
| Add a seventh health category                 | [Category palettes](./category-palettes.mdx)                                                     | 1              |
| Restyle one part of one component             | The component's own CSS variables                                                                | 3              |
| Redefine what "urgent" looks like             | Read [Status palettes](./status-palettes.mdx) first                                              | 1              |
| Change what "urgent" *means*                  | Not a theming question. See [Clinical status semantics](../health/clinical-status-semantics.mdx) | Not applicable |

The last two rows are the ones that matter. The status axis is the only part of
the token system opsinjs argues you should leave alone, and the argument is not
aesthetic: the four levels are a shared vocabulary between your product, this
documentation, the registry metadata and any agent generating against it. A
theme that quietly redefines them breaks a contract that is not written in CSS.

## Verify it worked [#verify-it-worked]

A theme is applied correctly when all four of these are true, and you can check
each of them in a browser in about a minute.

<Steps>
  ### The role tier resolves [#the-role-tier-resolves]

  Open devtools on any page of your app and inspect the computed value of
  `--background` on `:root`. It should be your value, not `oklch(1 0 0)`. If it is
  still the default, your override is being imported before the opsinjs layer
  rather than after it.

  ### The generated layer actually loaded [#the-generated-layer-actually-loaded]

  Inspect `--opsin-tokens-generated`. In a repository where `pnpm run generate`
  has run it holds the hash of the `tokens/` source. If it reads `placeholder`,
  you are looking at the committed fallback and any contrast figure you measure is
  measuring the wrong values.

  ### Dark mode swaps roles and not ramps [#dark-mode-swaps-roles-and-not-ramps]

  Toggle the `dark` class on `<html>`. Role tokens change. The *meaning* of every
  status level does not: watch is still watch, and its contrast against its own
  surface is still above the floor. If a status becomes unreadable in one theme
  only, that is the failure [Validating your theme](./validating-your-theme.mdx)
  exists to catch.

  ### Nothing carries two axes at once [#nothing-carries-two-axes-at-once]

  Find any element that shows both a category and a status. Exactly one of them
  should be expressed as colour. If a tile is both "sleep" and "urgent" and both
  are colours, the theme is applied but the composition is wrong. See
  [The two colour axes](../health/two-colour-axes.mdx).
</Steps>

## Troubleshooting [#troubleshooting]

**My colours apply in the docs but not in a preview.** Previews render in a
separate document under the product theme, not the docs chrome. That is
deliberate and it is explained in
[Lyra and the docs chrome](./lyra-and-the-docs-chrome.mdx).

**Tailwind utilities like `bg-card` do not pick up my values.** The role token is
defined but not exposed. Tailwind v4 needs the `@theme inline` mapping as well
as the custom property; declaring only the property gives you a working
`var(--card)` and a missing utility.

**My override works in development and disappears in a production build.**
Almost always CSS ordering. `app/globals.css` states its own required order in a
comment at the top of the file, and the position of `@import "./tokens.generated.css"`
inside it is fixed rather than incidental.

**`pnpm run check:generated` fails after I edited a token.** That is the check
doing its job: `app/tokens.generated.css` is generated and committed, so an edit
to the CSS instead of the JSON shows up as a diff. Change `tokens/*.json` and
regenerate.

**Contrast numbers on this site do not match what I measure in my theme.** They
are not supposed to. Every published figure describes the shipped presets. Your
theme is a different set of colours and needs its own measurement.

## Next [#next]

* [Theme generator](./theme-generator.mdx) is the shortest path: one brand
  colour in, a validated system out.
* [Validating your theme](./validating-your-theme.mdx) is the part most design
  systems leave to the reader, and the reason the generator exists.
* [Token reference](./token-reference.mdx) covers every token you may set,
  grouped by tier, with what it controls and who reads it.
