---
title: "Adding your own tokens"
description: "How to add a token the system does not have, in a way that survives an opsinjs upgrade instead of being overwritten by one."
url: "https://opsinjs.pensievelabs.org/theming/adding-your-own-tokens"
source: "https://opsinjs.pensievelabs.org/theming/adding-your-own-tokens.md"
section: "Theming & tokens"
kind: "guide"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["custom token", "extend tokens", "new token", "my own variables"]
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> Nothing is missing from this page. The data simply does not live in
> the prose.

<PageTemplate kind="guide" />

## Overview [#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: &#x2A;*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 [#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](./index.mdx).

## Put it in its own file [#put-it-in-its-own-file]

```css title="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:

```css title="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 [#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`:

```css title="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](./tailwind-v4.mdx) is
the full explanation of why this one keyword causes so much trouble.

## Respect the preference media queries [#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.

```css title="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 [#what-not-to-add]

<DoDont>
  <DoDont.Do>
    Add a category-shaped token for a category opsinjs does not ship, following
    [Category palettes](./category-palettes.mdx). Six ramps is a starting set,
    not a claim of completeness.
  </DoDont.Do>

  <DoDont.Dont>
    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](./status-palettes.mdx) explains what to do
    instead.
  </DoDont.Dont>
</DoDont>

<SafetyCallout severity="attention" evidence="opinion">
  Do not add a token whose name asserts a clinical judgement, such as
  `--acme-normal`, `--acme-danger` and `--acme-abnormal`. A colour token cannot
  know a reference range, and a name like that invites a component to decide
  something only a clinician or a validated rule may decide. Name the appearance,
  not the verdict, and get the verdict from the status axis.
</SafetyCallout>

## Verify it worked [#verify-it-worked]

<Steps>
  ### The token resolves [#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 [#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 [#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.
</Steps>

## Troubleshooting [#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 [#next]

* [Tailwind v4](./tailwind-v4.mdx) covers `@theme` against `@theme inline`, and
  the ordering that breaks silently.
* [CSS variables](./css-variables.mdx) covers consuming any of this without
  Tailwind at all.
* [Contributing tokens](../handbook/contributing/contributing-tokens.mdx) is
  where to go if the token you need probably belongs in opsinjs rather than in
  your app.
