opsinjs
FoundationsTheming & tokens

Adding your own tokens

How to add a token the system does not have, in a way that survives an opsinjs upgrade instead of being overwritten by one.

Overview

Sooner or later your product needs something opsinjs does not have: a colour for a partner brand, a second surface elevation, a duration for an animation nobody here anticipated. Adding it is easy. Adding it so that it is still there, unchanged and un-clobbered, after you take an opsinjs update is the part this page is about.

There is exactly one rule and everything else follows from it: write your tokens in a file opsinjs does not generate, using a prefix opsinjs will never emit. The failure mode this avoids is not a merge conflict. A conflict would be fine, and you would see it. It is a silent overwrite by a generator that assumes it owns every declaration in the file it writes.

Choose a prefix

Prefix every custom property you add with your own namespace: --acme-, not --opsin- and not a bare --brand-.

--opsin-* is generated. Anything you declare with that prefix is at risk from the next pnpm run generate in a fork, and is guaranteed to confuse the next person who greps for where a token comes from. A bare, unprefixed name risks colliding with a role token that shadcn or opsinjs adds later. --surface and --elevation are exactly the kind of name a design system adds in a minor version.

The one deliberate exception: overriding an existing tier 2 role is not adding a token, it is retheming, and it uses the existing name. See the three tiers.

Put it in its own file

app/tokens.custom.css
/* Owned by us. opsinjs never writes here. */
:root {
  --acme-partner-accent: oklch(0.58 0.11 190);
  --acme-partner-accent-ink: oklch(0.3 0.06 190);
  --acme-duration-marketing: 640ms;
}

.dark {
  --acme-partner-accent: oklch(0.74 0.1 190);
  --acme-partner-accent-ink: oklch(0.18 0.03 190);
}

Then import it after the opsinjs layer, so that your declarations win by cascade order rather than by specificity tricks:

app/globals.css
@import "./tokens.generated.css";
/* … the opsinjs token layer … */
@import "./tokens.custom.css";

Two files rather than one is the whole upgrade strategy. A generated file is replaced wholesale by its generator; a hand-written file next to it is never touched. When you later diff an opsinjs upgrade, the diff contains only generated changes and your file does not appear in it at all.

Expose it to Tailwind

Declaring the property gives you var(--acme-partner-accent). It does not give you bg-acme-partner-accent. For that, map it in @theme inline:

app/tokens.custom.css
@theme inline {
  --color-acme-partner: var(--acme-partner-accent);
  --color-acme-partner-ink: var(--acme-partner-accent-ink);
  --animate-duration-marketing: var(--acme-duration-marketing);
}

inline matters. Without it Tailwind resolves the value at build time and your runtime overrides stop reaching the utility. Those overrides are dark mode, the P3 escalation and a data-density attribute. Tailwind v4 is the full explanation of why this one keyword causes so much trouble.

Respect the preference media queries

If your token participates in anything the system already degrades, degrade it in the same place. A custom duration that ignores prefers-reduced-motion is not a token, it is a bug with a name.

app/tokens.custom.css
@media (prefers-reduced-motion: reduce) {
  :root {
    --acme-duration-marketing: 1ms;
  }
}

The three the system honours today are prefers-reduced-motion, prefers-reduced-transparency and color-gamut: p3. If you add a colour, add the P3 escalation at the same lightness and hue as the sRGB value, so that the measured contrast of your pair does not change between displays.

What not to add

Do

Add a category-shaped token for a category opsinjs does not ship, following Category palettes. Six ramps is a starting set, not a claim of completeness.

Don’t

Add a fifth status level. The four levels are a vocabulary shared by your product, this site, the registry metadata and any agent generating against it; a fifth exists only in your codebase and everything else will map it to something else. Status palettes explains what to do instead.

Verify it worked

The token resolves

Inspect any element and confirm your custom property has a computed value. An undefined custom property is not an error in CSS; it silently produces an invalid declaration, and the element simply looks slightly wrong.

The utility exists

Apply the Tailwind class you mapped. If the class produces no rule, either the @theme inline block is missing or the file it lives in is not covered by a @source line. Tailwind only scans files it has been told about.

An upgrade does not touch it

Run pnpm run generate and then check git status. Your custom file must not appear. If it does, you have written into a generated file.

Troubleshooting

The value is right in devtools but the component ignores it. The component reads a role token, not yours. Adding a token does not make anything use it; either set the role, or use the component's own tier-3 variable.

It works until dark mode. You declared the token under :root only. Custom properties do not derive a dark value on their own.

pnpm run check:generated fails and blames a file I did not edit. You edited app/tokens.generated.css or lib/generated/tokens.ts. Move the declaration to your own file.

Prettier keeps reformatting the file. app/globals.css and app/product.css are deliberately in .prettierignore because their order is their content. Your own token file has no such constraint and should stay formatted.

Next

  • Tailwind v4 covers @theme against @theme inline, and the ordering that breaks silently.
  • CSS variables covers consuming any of this without Tailwind at all.
  • Contributing tokens is where to go if the token you need probably belongs in opsinjs rather than in your app.

On this page