opsinjs
FoundationsTheming & tokens

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 toDo thisTier
Apply your brand colourRun the theme generator, paste the CSS it emits2
Ship a second brand for a white-label buildPublish a preset code per brand2
Change corner radius, spacing, or the UI fontSet --radius, --spacing, --font-sans2
Add a token the system does not haveFollow Adding your own tokens2
Add a seventh health categoryCategory palettes1
Restyle one part of one componentThe component's own CSS variables3
Redefine what "urgent" looks likeRead Status palettes first1
Change what "urgent" meansNot a theming question. See Clinical status semanticsNot 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.

On this page