Theming & tokens
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.
Overview
Three pillars of this site talk about tokens and they do not overlap. 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. 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. A theme you build against the tokens is correct against the components that have since landed.
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
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 and
Status palettes for what that costs on each axis. The
answer is very different for the two.
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 explains why inline
is load-bearing and what silently breaks without it.
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.
The rule that follows from the tiers: 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.
Where you may intervene
| You want to | Do this | Tier |
|---|---|---|
| Apply your brand colour | Run the theme generator, paste the CSS it emits | 2 |
| Ship a second brand for a white-label build | Publish a preset code 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 | 2 |
| Add a seventh health category | Category palettes | 1 |
| Restyle one part of one component | The component's own CSS variables | 3 |
| Redefine what "urgent" looks like | Read Status palettes first | 1 |
| Change what "urgent" means | Not a theming question. See Clinical status semantics | 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
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.
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
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
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
exists to catch.
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.
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.
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
- Theme generator is the shortest path: one brand colour in, a validated system out.
- Validating your theme is the part most design systems leave to the reader, and the reason the generator exists.
- Token reference covers every token you may set, grouped by tier, with what it controls and who reads it.
Sound and haptics
Whether urgency may be carried by a buzz or a chime, the three-pattern haptic vocabulary, and why opsinjs specifies both channels but ships neither.
Theme generator
Turn one brand colour into a complete, gamut-aware, contrast-validated opsinjs theme, and understand every step it took to get there.