opsinjs
FoundationsTheming & tokens

Category palettes

How to add or retune a health category ramp without letting it leak into the axis that carries clinical urgency.

Overview

opsinjs ships six category ramps: sleep, heart, activity, nutrition, mind and labs. Six is a starting set rather than a claim about the shape of human health. A cycle-tracking app needs a seventh. A medication-adherence app needs one that is not a body system at all. Adding one is supported, it is a tier-1 change, and it has exactly one hard constraint.

Those six ids are the ones tokens/color.json defines and the ones app/tokens.generated.css emits as --opsin-category-<id>-*. There is no seventh in the box, and in particular there is no cycle. The cycle ramp you will find in tokens/color.json is filed under categoryExtensionExample and its own comment begins "NOT SHIPPED". It is the worked example on this page, not a category.

A category colour is identity. It carries no verdict. What a category means, and why it is deliberately duller than you would choose if you were picking brand colours, is settled in Category identity. This page assumes you accept that and shows you how to add one without breaking it.

The constraint, stated as a test

Before you add a ramp, decide its hue against this test: could a reasonable person mistake this colour for a status?

The four status seeds sit at OKLCH hues 27 (urgent), 45 (attention), 82 (watch) and 178 (steady). A new category at 40° is not a new category; it is a fifth status that nobody declared, and the first time it appears next to an actual status the screen becomes ambiguous. The system cannot detect this for you, because both are just colours. That is precisely why it is written down as a rule instead of enforced by a type.

Two practical consequences:

  • Stay off the three warm status hues. 27, 45 and 82 are taken, and the arc between them is where a reader has learnt to expect a verdict. The shipped categories that sit nearest are heart at 15 and nutrition at 70, and both of them are doing work a newcomer should not assume is available again.
  • Watch 178 as well. steady is the one status that is not warm, so "avoid the warm arc" is not the whole rule: a teal category at 170 to 190 will read as this is fine whether or not you meant it.

Chroma will not save you. The shipped category seeds run from 0.11 (labs) to 0.17 (heart), and the shipped status seeds from 0.11 (steady) to 0.20 (urgent). The two ranges overlap almost completely, so there is no chroma threshold that separates the axes. Hue separation and the word beside the colour are what separate them. If you want a rule of thumb, keep a new category at least 30° away from 27, 45, 82 and 178, and check it against Colour blindness rather than trusting your own eyes.

Add a ramp

A category is defined in tokens/color.json and generated from there. Editing the CSS directly is caught by pnpm run check:generated.

Add the source entry

Give it a kebab-case id, a hue, and the chroma ceiling you want the generator to respect. The id becomes the token name and the data-category value, so pick one you can live with: renaming a category later changes markup, registry metadata and any saved user preference that stored it.

Regenerate

pnpm run generate

This rewrites app/tokens.generated.css and lib/generated/tokens.ts, which adds --opsin-category-<id>-accent, -surface and -ink in light and dark, plus the Display-P3 escalation at identical lightness.

Expose the Tailwind utilities

Add the three @theme inline mappings alongside the existing categories, so text-category-<id> and friends exist. The generator writes the custom properties; the mapping is authored.

Measure it

pnpm run contrast

Every new pair is measured with APCA and WCAG 2.2 and compared to the published floor. A category ink that fails against its own surface is a failure exactly as much as a status one is. The colour is quieter, but the text is not.

Retune an existing ramp

Same mechanism, more caution. A category ramp is referenced by chart series colours, by section identity and by small glyphs. Critically, it is referenced by whatever your users have already learned. Moving sleep from violet to teal is a re-education cost paid by every existing user, so treat it as a versioned change: record it in your changelog, not in a patch release.

If your reason for retuning is "it does not match our brand", consider instead whether the category ramps should be visible in brand-heavy surfaces at all. Categories earn their place on data screens.

Keep the axes apart

Do

Give an element its category from a glyph, a label or a chart-line colour, and its status from the surface it sits on. One axis per channel.

Don’t

Tint a status surface toward the category hue "so it feels cohesive". The result is a colour that is neither, and a reader who has learned that red-ish means act now has to relearn it per category.

The interactive form of this rule is <StatusAxisDemo>, which refuses to render a category-plus-status pair on one element and explains what it would have meant. It is on The two colour axes and in the status playground at /playground/status.

For product code, the cheapest enforcement is a lint rule rather than a review convention. See Handbook tooling.

Verify it worked

The ramp exists in both themes

--opsin-category-<id>-accent resolves under :root and under .dark, and the two are different values. A category that is identical in both themes was authored once and mirrored, which almost always fails contrast in one of them.

It survives colour-blind simulation

Run the preview through <CvdSimulator> for protanopia, deuteranopia, tritanopia and grayscale. A category must remain distinguishable from the other categories; it does not need to remain identifiable, because nothing important should depend on identifying it by colour alone.

It does not read as a status

Put it directly beside the four status swatches at the same size. If your eye sorts it into that group, the hue or the chroma is wrong.

Troubleshooting

My new category is invisible against the card surface. Category accent is tuned for lines and glyphs, not for text. Use the ink slot for text on the category surface and check it with pnpm run contrast.

The colour is much more saturated on my phone. That is the P3 escalation. Confirm the escalated value shares the sRGB value's lightness and hue; if the generator produced something else, the source entry has a chroma ceiling that the escalation is ignoring.

The utility class does not exist. The @theme inline mapping is authored, not generated. Add it.

check:generated fails after I added the mapping. The mapping belongs in app/globals.css and app/product.css, which are hand-written. If you put it in app/tokens.generated.css, the generator has already thrown it away.

Next

On this page