---
title: "Colour tokens"
description: "The generated reference for every colour token. It carries the naming grammar, both themes, both gamuts, and the measured pair each one belongs to."
url: "https://opsinjs.pensievelabs.org/foundations/colour/tokens"
source: "https://opsinjs.pensievelabs.org/foundations/colour/tokens.md"
section: "Foundations"
kind: "foundation"
reviewed: "2026-09-02"
reviewer: "engineering"
aliases: ["colour tokens", "color tokens", "css variables colour", "opsin-status", "opsin-category"]
---

> 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="foundation" />

## Overview [#overview]

This page is the list. Everything on it is generated from `tokens/color.json` by
`scripts/build-tokens.mts`; nothing on it is typed by a person, and
`pnpm check:generated` fails the build if the committed output and a fresh
regeneration disagree.

Use it to answer three questions: *what is this token called*, *what does it
control*, and *who else uses it*. The third column is what makes a token table a
decision aid instead of an inventory. A token nothing consumes is a token that
should not exist, and a token six components consume is one you cannot change
casually.

If you want the argument rather than the list, start at
[Colour](./index.mdx). If you want to change a value, you are in the wrong
pillar: [Theming](../../theming/index.mdx) is where changes live.

## How it works [#how-it-works]

### The naming grammar [#the-naming-grammar]

```text
--opsin-<axis>-<name>-<role>          the two clinical axes
--<chrome-role>                       the neutral application palette
--color-<…>                           the Tailwind utility alias, via @theme inline
```

The clinical axes are always prefixed `--opsin-`. The neutral palette
(`--background`, `--foreground`, `--card`, `--muted`, `--border`, `--ring`,
`--primary` and their foregrounds) is unprefixed, because it follows the
convention a shadcn-based codebase already has and there is nothing to be gained
by renaming it. [Colour roles](./colour-roles.mdx) explains the split.

### What is emitted, and in what order [#what-is-emitted-and-in-what-order]

`app/tokens.generated.css` contains, in this order:

1. a `:root` block holding the light theme,
2. a `.dark` block holding the dark theme,
3. `@media (color-gamut: p3)` blocks for each of the above, escalating chroma
   only ([Gamut and Display-P3](./gamut-and-p3.mdx)),
4. the preference-query overrides for `prefers-reduced-transparency` and
   `prefers-reduced-motion`, which belong to other foundations.

`app/globals.css` imports that file at a fixed position between the vendor theme
blocks and `@layer base`, and the product theme in `app/product.css` imports the
same file. One source of truth, two shells.

### Reading a resolved value [#reading-a-resolved-value]

<TokenSwatch token="--opsin-status-urgent-surface" />

A swatch shows the token's resolved value in both themes and the pair it was
measured in. A single contrast number attached to one colour would be
meaningless. Contrast is a property of a pair, which is why every figure on this
site names both halves.

## Using it [#using-it]

**In Tailwind**, use the utility alias: `bg-status-attention-surface`,
`text-status-attention-ink`, `border-category-heart`. The aliases are declared with
`@theme inline`, so they resolve the custom property at use time and therefore
follow the theme, the gamut escalation and every preference override
automatically. `@theme` without `inline` would bake the value in at build time
and none of that would work; the trap is documented in
[Tailwind v4](../../theming/tailwind-v4.mdx).

**Outside Tailwind**, use `var(--opsin-status-attention-surface)` directly. Nothing in
the token layer depends on Tailwind. The layer is plain custom properties in a
plain stylesheet, which is the reason a non-Tailwind consumer is a supported
case rather than a workaround. See
[CSS variables](../../theming/css-variables.mdx).

**Never inline a resolved value.** A token exists so that a theme change reaches
every consumer. A copied `oklch()` is a value that will be correct until the
first time somebody changes the theme, at which point it becomes a bug nobody
can grep for. The lint rules described in the
[Handbook](../../handbook/index.mdx) catch this before review does.

**Do not compose a new colour from two tokens.** Mixing, fading or overlaying
tokens produces a colour that is in no measured pair. If you need a lighter
variant, the answer is a different role or a
[material rung](../materials/the-ladder.mdx), not `color-mix()`.

## Tokens [#tokens]

<TokenTable scope="color" />

## Accessibility impact [#accessibility-impact]

Every row above belongs to a measured pair, and the measurements are published
rather than asserted. `scripts/check-contrast.mts` groups its output by token
scope rather than by source file, so the colour namespace arrives as three
tables rather than one.

The status stems, which are the pairs a reader's safety depends on:

<ContrastReport scope="status" />

The six category ramps, whose `-line` and `-ink` roles carry the identity marks:

<ContrastReport scope="category" />

And the neutral ramp, which every other pair is eventually measured against:

<ContrastReport scope="neutral" />

Three properties of those tables are load-bearing for accessibility and worth
stating explicitly, because a generated table looks like it could not possibly
be wrong:

* **They are regenerated on every commit.** A hand-edit to the emitted CSS fails
  `pnpm check:generated`, so the published values and the shipped values cannot
  drift apart.
* **They cover both themes.** There is no row that is measured in light and
  assumed in dark.
* **They do not cover pairs you assemble yourself.** The declared pair set is
  enumerated in [Colour roles](./colour-roles.mdx). A combination outside it is
  unmeasured, and unmeasured is the honest word for it.

## Related [#related]

* [Colour roles](./colour-roles.mdx) says what `surface`, `line` and `ink`
  mean, and why a guarantee attaches to a pair.
* [Generated token reference](../../reference/generated/tokens.mdx) has the
  same data for the whole system, not just colour.
* [Theming](../../theming/index.mdx) is the pillar where you change any of this.
