---
title: "Colour roles"
description: "The four roles every colour token plays are surface, line, ink and accent. A contrast guarantee attaches to a pair, never to a colour."
url: "https://opsinjs.pensievelabs.org/foundations/colour/colour-roles"
source: "https://opsinjs.pensievelabs.org/foundations/colour/colour-roles.md"
section: "Foundations"
kind: "foundation"
reviewed: "2026-09-02"
reviewer: "design"
aliases: ["surface line ink", "semantic colour", "token naming", "measured pair", "role tokens"]
---

> 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]

A ramp of eleven steps is not a colour system, because it does not tell you
which step to use. Roles are the layer that does. The four opsinjs roles are
`surface`, `line`, `ink` and `accent`, and there are four rather than thirty
because three is the number of distinct *contrast obligations* a colour can
have, plus one deliberate exception. `accent` clears no floor of its own: it is
chosen for recognition rather than for legibility, which is exactly why it may
never carry text and may never be an element's only boundary.

The important idea on this page is not the naming. It is this:

> A contrast guarantee attaches to a **pair**, not to a colour.
>
> `--opsin-status-attention-ink` is not "an accessible colour". It is the foreground
> half of a measured pair whose background half is
> `--opsin-status-attention-surface`. Put it on anything else and you have left the
> guarantee behind.

Everything below follows from that sentence.

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

### The four roles [#the-four-roles]

| Role      | Sits                                                | Measured against              | Obligation                                  |
| --------- | --------------------------------------------------- | ----------------------------- | ------------------------------------------- |
| `surface` | behind content                                      | it is the background half     | none of its own; it defines the pair        |
| `line`    | on a surface, as a stroke or glyph                  | its own surface               | non-text contrast                           |
| `ink`     | on a surface, as text or an icon that carries words | its own surface, and the page | text contrast                               |
| `accent`  | as an identity fill or a chart mark                 | its own surface, and the page | none it must clear; both pairs are advisory |

`line` and `ink` are separate roles rather than one "foreground" role because
their floors differ, and collapsing them means either over-darkening every chart
stroke or under-darkening every label. A stroke that is 2px wide and 40px long
is not held to the same figure as a 15px sentence, and pretending otherwise
produces a system whose charts are muddy.

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

```text
--opsin-<axis>-<name>-<role>

axis  ∈ { category, status }
name  ∈ { sleep, heart, activity, nutrition, mind, labs }   when axis = category
      ∈ { steady, watch, attention, urgent, unknown }       when axis = status
role  ∈ { surface, line, ink, accent }
```

`unknown` is a fifth status *stem* and deliberately not a fifth level. It is the
absence of an assertion, which covers a reading never taken, a stale reading, or
a reading whose reference range the product does not own. It is therefore never
ordered against the four; see
[Two colour axes](../../health/two-colour-axes.mdx).

There is no fourth *segment*. No `-hover`, no `-subtle`, no `-2`. Interaction
states are derived from the role token at use time, in OKLCH, by the rules in
[Interaction states](../interaction-states.mdx). They are not additional tokens,
because a hover variant of every role on every one of the eleven stems is
another forty-four tokens nobody can hold in their head.

Tailwind utilities are exposed for each token through `@theme inline`, so
`bg-status-attention-surface` and `text-status-attention-ink` resolve to the same custom
properties and follow the theme, the gamut escalation and the media-query
overrides automatically. Two of those utilities are shorter than they look, and
the two axes disagree about which role the bare name means: `bg-status-urgent`
resolves to the **line**, `bg-category-heart` resolves to the **accent**. The
other roles are always spelt out. Raw `var()` access is supported for consumers
who are not on Tailwind; see [CSS variables](../../theming/css-variables.mdx).

The category axis also exposes one role fewer, and this is the rule that keeps
the two axes apart. `surface` is bridged to a utility only on the status axis, so
a status can tint a card with `bg-status-attention-surface` while a category has
`line`, `ink` and `accent` and no surface utility to reach for. The reason is not
chroma. Both axes run the same lightness ladder and the same chroma envelope, so
at a given step a category surface and a status surface measure Lc 0.00 and WCAG
1.00 against each other, which is no difference a reader could use. So a category
paints a stroke, a word or an identity fill and never a tinted card, and only a
status owns the tinted surface. A category surface value still exists as the
measurement reference its `ink` is gated against, and it is what the ramp
specimen shows, but nothing paints a card with it.

### Chrome roles are a separate namespace [#chrome-roles-are-a-separate-namespace]

Alongside the two axes there is an ordinary application palette.
`--background`, `--foreground`, `--card`, `--muted`, `--muted-foreground`,
`--border` and `--input` are inherited from the shadcn convention so that a team
already using it is not asked to relearn anything. These seven are unprefixed on
purpose: they are the *neutral* system, they carry no clinical meaning, and they
are the correct default for almost everything on a screen.

Two more roles ship under the same convention and are not neutral. `--ring` and
`--primary` are a brand blue, seeded at chroma 0.13 and 0.135, which is as
chromatic as the category axis, whose labs seed sits at 0.11 and whose heart
seed at 0.17. Calling them neutral would be wrong. They carry no clinical
meaning either, but they are a third saturated presence on the screen, one that
competes with both measurement axes for the reader's eye, so a focus ring or a
primary button is a deliberate accent rather than a quiet default. Whether the
chrome palette should stay this chromatic is a design question the system has
not yet closed.

The `--opsin-*` tokens are the exception you reach for when the element is
genuinely saying something about a measurement.

If most of your screen is coloured from the two axes, the screen is shouting.
See [Alarm fatigue](../../health/alarm-fatigue.mdx).

### The declared pair set [#the-declared-pair-set]

`scripts/check-contrast.mts` measures a *declared* list of pairs, in both
themes and in sRGB. Each measured pair is gated, advisory or exempt, the
three states [Contrast and APCA](./contrast-and-apca.mdx) defines, and the
clauses below say which state each pair takes rather than restating what the
states mean. The list is, exactly:

1. every `ink` against the `surface` of the same axis and name, at the body-text
   floor;
2. every `line` against that same surface, at the non-text floor;
3. every `accent` against that same surface **and** against the page, at the
   non-text floor and advisory rather than gated, because an identity fill is
   chosen for recognition and is not something a reader has to read;
4. every `ink` against the page, at the body-text floor, because a status or
   category word frequently sits on the page rather than on its own tint;
5. the neutral roles: body text and secondary text against the page at the
   body-text floor; the card hairline and the focus ring against both the page
   and a card at the non-text floor, gated on the chrome tokens the theme
   renders; and one grouping hairline between list rows against the page, left
   advisory because a row divider carries no meaning that SC 1.4.11 covers;
6. body text against every material rung's opaque fallback, `scrim` excepted
   because nothing is written on a dimmer (see
   [The contrast floor](../materials/the-contrast-floor.mdx)).

"The page" in clauses 3 to 6 is `--opsin-chrome-background`, which resolves to
`--opsin-neutral-50` in light and `--opsin-neutral-950` in dark. It is not
`--card`, which is `--opsin-chrome-card` and resolves to `--opsin-neutral-0` in
light and `--opsin-neutral-900` in dark. In both themes the card sits one ladder
step lighter than the page, so a card is one step away from the measured pair
rather than inside it.

Anything not on that list is **unmeasured**. Unmeasured is not the same as
failing, because an unmeasured pair may well be fine. It is still not something
this system is promising you, and a health product should not be built on a
promise nobody made. The focus ring used to be the gap worth knowing about, and
it no longer is: `--ring` against the page and against a card is measured and
gated in both themes, on the chrome tokens the theme actually renders, so the
boundary a keyboard reader depends on is now something the system checks rather
than something it hopes.

<Todo>
  One honest gap survives the measurement. A 1px hairline on a near-black dark
  page cannot reach the APCA Lc 45 non-text floor at any value the neutral ladder
  holds, so the dark card hairline is measured, published and below the APCA floor
  while it still clears the WCAG 3:1 ratio. It is gated rather than hidden, which
  catches any further lightening of the border, and the shortfall is stated at its
  source in `app/product.css`. Closing it needs a boundary treatment the neutral
  ladder does not yet carry.
</Todo>

The neutral and chrome pairs, measured in both themes, render below. The card
hairline rows sit in the neutral scope, and the focus ring rows in the chrome
scope, because the ring is a chrome role rather than a neutral one.

<ContrastReport scope="neutral" />

<ContrastReport scope="chrome" />

## Using it [#using-it]

**Use a whole pair or neither half.** A status or category *word* on a neutral
card is `ink`, not `line`: `ink` against the page is clause 4, measured and
gated in both themes, while `line` is tuned to the non-text floor, so moving a
word onto it lowers the contrast the reader actually gets. Two cautions come
with that. The measured ground is the page rather than `--card`, which is the
same colour in light and one step lighter in dark, so in dark a word on a card
inherits a guarantee made a step away from it. And `line` on a neutral
ground is not measured at all. It is the role for a stroke, a boundary or a
chart mark, and its floor is guaranteed only against its own surface.

**Never take `ink` from one axis onto a `surface` from the other.** Heart ink
on an urgent surface is both an unmeasured pair *and* a violation of the
never-mix rule in [Two colour axes](../../health/two-colour-axes.mdx). It is the
most common way the axis invariant is broken in practice, because each half
looks defensible on its own.

**Do not invent a role to solve a layout problem.** If a surface needs a
slightly different tint to separate it from its parent, the answer is a
[material rung](../materials/the-ladder.mdx) or a border, not a new colour token.

<DoDont>
  <DoDont.Do>
    A tinted status card: `--opsin-status-attention-surface` behind
    `--opsin-status-attention-ink`, with a 1px `--opsin-status-attention-line` edge. All
    three halves come from one axis and one name, so every pair on the card is
    in the declared set and measured in both themes.
  </DoDont.Do>

  <DoDont.Dont>
    A neutral card with `--opsin-status-attention-accent` for the heading
    "because the ink read too dark". The accent role is chosen for recognition
    rather than legibility: its two pairs are measured at the non-text floor and
    are advisory even there, so the heading is now text held to no text floor at
    all. The reader also has a coloured heading with no coloured surface to
    explain what the colour means.
  </DoDont.Dont>
</DoDont>

**Extending is a supported operation; redefining is not.** Adding a seventh
category adds four tokens and no risk, because categories carry no verdict.
Redefining a status role changes what the product asserts about somebody's
health, which is why [Status palettes](../../theming/status-palettes.mdx) exists
as a page-length argument rather than a recipe.

## Tokens [#tokens]

<TokenTable scope="color" />

## Accessibility impact [#accessibility-impact]

The pair rule is the accessibility mechanism on this page, and it has a
consequence people find unintuitive: &#x2A;*a token cannot be accessible on its
own.** Contrast is a relationship. A design review that says "this colour passes"
has not said anything checkable, and a token table that prints a single contrast
number per colour is printing a number against an assumed background it did not
declare.

That is why [Contrast and APCA](./contrast-and-apca.mdx) and every
`<ContrastReport>` on this site print a *pair*, a *theme* and a *floor*, and why
`<TokenSwatch>` shows a token's resolved value in both themes together with the
pair it was measured in.

Two related obligations sit with the implementer rather than with the tokens:

* **The `line` role is not a text role.** If you set body copy in a `line`
  colour it may well be below the text floor. The role name is the warning.
* **Icons that carry words are `ink`, not `line`.** An icon whose meaning is
  duplicated by adjacent text is decorative and may use `line`; an icon that is
  the only carrier of a piece of information is text as far as WCAG 2.2 SC 1.4.3
  is concerned, and it should not exist at all under
  [Colour independence](../../accessibility/colour-independence.mdx).

## Related [#related]

* [How the engine works](./how-the-engine-works.mdx) covers both stages.
  Stage 6 is where a ramp becomes these four roles, and stage 7 is where the
  pairs are validated.
* [Interaction states](../interaction-states.mdx) explains why hover, press and
  selected are derived at use time rather than shipped as more tokens.
* [Token architecture](../token-architecture.mdx) has the three-tier model, and
  the rule that a component never references a primitive.
