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
/* 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:
@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:
@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.
@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
@themeagainst@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.