Colors

The OKLCH color space, the 19-hue primitive palette, the semantic color roles, and WCAG guidance for using them.

Color space, OKLCH

Every primitive color is defined in OKLCH.

ChannelRangeMeaning
L0 to 1Perceptual lightness, 0 is black and 1 is white
C0 to about 0.4Chroma, or colorfulness, where 0 is gray
H0 to 360 degreesHue angle

Why OKLCH rather than hex or HSL:

  • Perceptually uniform. Equal steps in L produce equal perceived brightness differences, whatever the hue. HSL does not guarantee this: hsl(60, 100%, 50%) yellow looks far brighter than hsl(240, 100%, 50%) blue at the same stated lightness.
  • Predictable contrast. You can reason about WCAG contrast by comparing L values, without converting to relative luminance first.
  • Better interpolation. Gradients and animations between two OKLCH colors do not pass through muddy grays.
  • Composable. The relative color syntax, oklch(from var(--color-text) l c h / 0.6), lets a token derive from another without duplicating its value. Several semantic tokens in this package rely on it.

Browser support is Chrome 111, Firefox 113 and Safari 15.4 and above. No sRGB fallback is generated, so every value ships as oklch() and anything older needs a fallback of its own.

Primitive palette

19 hues, 11 steps each (50 to 950), plus flat --color-black and --color-white. These are raw values with no opinion about usage.

HueH angleCharacter
amber70Golden yellow-orange
blue260Classic blue
cyan215Bright cyan
emerald162Deep blue-green
fuchsia322Vivid magenta
gray264Cool neutral, chroma about 0.02
green150Lush green
indigo277Blue-violet
lime131Electric yellow-green
orange48Vivid orange
pink354Bright pink
purple304Rich purple
red25Classic red
rose16Pink-red
sienna24Brick-red, the default brand
sky237Soft sky blue
teal183Blue-green
violet293Modern violet
yellow86Pure yellow

Step guide

L values below are measured from the actual palette, not estimated.

StepL, cool huesL, amber / yellow / limeL, grayTypical use
500.970.990.98Tinted page backgrounds, hover on white
1000.950.970.97Muted backgrounds, badges, tags
2000.900.940.93Borders, dividers
3000.830.890.87Disabled elements, placeholder text
4000.730.840.71Secondary icons, decorative
5000.660.780.56Mid-tone, pair with dark text
6000.580.670.44Default brand and status background. White text passes WCAG AA for UI
7000.500.550.37Hover state, colored text on white
8000.440.470.28Deep accents, high-contrast text
9000.390.410.21Near-dark, very high contrast
9500.270.280.14Darkest tint, almost black

Two things to read out of that table. Intrinsically bright hues (amber, yellow, lime) carry noticeably higher L at steps 400 to 700; that is expected behaviour of a perceptual space, not a calibration error. Gray goes the other way and runs darker than the chromatic hues from step 400 down, because it has almost no chroma to contribute to perceived brightness.

Semantic color tokens

Semantic tokens are named by purpose and reference primitives through var().

Brand and accent

--color-brand         /* primary brand color: button background, active states */
--color-brand-hover   /* hover state */
--color-brand-muted   /* tinted background for brand areas */
--color-brand-strong  /* darkest brand shade */

--color-accent        /* equals brand by default, override independently if needed */

The default brand is sienna, a warm brick-red. --color-accent and its variants alias brand, so overriding brand alone moves both.

@layer tokens {
  :root {
    --color-brand:        var(--color-violet-600);
    --color-brand-hover:  var(--color-violet-700);
    --color-brand-muted:  var(--color-violet-100);
    --color-brand-strong: var(--color-violet-900);
  }
}

Backgrounds

TokenDefaultUsage
--color-backgroundwhitePage background
--color-background-surfaceequals --color-backgroundCard and panel backgrounds
--color-background-mutedgray-100Subtle section backgrounds
--color-background-muted-hovergray-200Hover on a muted surface
--color-background-mediagray-200Image placeholders, skeletons
--color-background-disabledgray-100Disabled controls
--color-background-accentequals --color-accentHighlighted sections

Text

TokenDefaultUsage
--color-textgray-900Body text
--color-text-hovergray-700Text hover
--color-text-muted--color-text at --opacity-mutedSecondary text, captions
--color-text-disabledgray-300Disabled UI
--color-headingblackHeadings
--color-linkequals --color-textDefault link color
--color-link-hoverequals --color-accentLink hover
--color-link-activeequals --color-activeCurrent link
--color-creditequals --color-text-mutedBylines, captions

--color-text-muted is derived rather than aliased: oklch(from var(--color-text) l c h / var(--opacity-muted)). It therefore follows any override of --color-text automatically, which is why it is not simply an alias of gray-500. The dark theme is the one place that overrides it outright, to gray-400, because a transparent text color over a dark background does not mute the same way it does over a light one.

Text on colored backgrounds

These exist so that contrast holds when a color becomes the background.

--color-text-on-brand      /* white */
--color-text-on-accent     /* white */
--color-text-on-dark       /* white */
--color-text-on-black      /* white */
--color-text-on-light      /* gray-900 */
--color-text-on-white      /* black */
--color-text-on-surface    /* equals --color-text */
--color-text-on-muted      /* gray-900 */
--color-text-on-neutral    /* white */
--color-text-on-danger     /* white */
--color-text-on-info       /* white */
--color-text-on-success    /* white */
--color-text-on-highlight  /* gray-900 */
--color-text-on-warning    /* gray-900, amber is bright so dark text is required */

Status and surface variants

TokenPrimitiveNotes
--color-dangerred-600Errors, destructive actions
--color-successgreen-600Confirmations
--color-warningamber-500Warnings, pair with --color-text-on-warning
--color-infoblue-600Informational
--color-highlightyellow-100Marked or highlighted text
--color-neutralgray-500Neutral emphasis
--color-darkgray-900Dark surfaces
--color-lightgray-200Light surfaces

Each of these carries a -hover, -muted and -strong companion:

--color-danger:        var(--color-red-600);
--color-danger-hover:  var(--color-red-700);
--color-danger-muted:  var(--color-red-100);
--color-danger-strong: var(--color-red-800);

Derived utility colors

Four tokens use relative color syntax rather than an alias, so they track their base automatically:

TokenDerived from
--color-backdrop--color-black at --opacity-backdrop
--color-shadow-light--color-shadow at --opacity-shadow
--color-shadow-medium--color-shadow at --opacity-backdrop
--color-shadow-strong--color-shadow at --opacity-overlay

--color-shadow is one of the tokens the dark theme flips, from black to white. Because the three shadow variants derive from it, the whole shadow system inverts with a single override. See Dark mode.

Accessibility

RatioRequirement
4.5 : 1Normal text, under 18px, or non-bold under 14px. WCAG AA
3 : 1Large text and UI components such as buttons, inputs, icons. WCAG AA
7 : 1Any text. WCAG AAA

Rules of thumb for this palette:

  • White text on a colored background needs step 600 or darker. Step 500 and below typically land around 3 to 3.5 : 1, which fails for normal text.
  • Colored text on white needs step 700 or darker for normal text.
  • Warning is the exception that proves the rule. --color-warning is amber-500, whose L is about 0.78, so it must be paired with --color-text-on-warning (gray-900). Never put white text on it.
  • Decorative use is unconstrained. Any step is fine when color carries no information, as in borders, illustrations or icons that duplicate a visible label.

These are starting points, not a substitute for measuring. Contrast depends on both colors, and an override of --color-brand invalidates the assumptions above.

Adding a custom hue

Add a primitive scale in tokens/primitive/color.json, one DTCG color object per step:

{
  "color": {
    "coral": {
      "50":  { "$value": { "colorSpace": "oklch", "components": [0.975, 0.014, 35.0] }, "$type": "color" },
      "100": { "$value": { "colorSpace": "oklch", "components": [0.948, 0.032, 35.0] }, "$type": "color" },
      "950": { "$value": { "colorSpace": "oklch", "components": [0.225, 0.078, 35.0] }, "$type": "color" }
    }
  }
}

Run npm run build, then reference the generated --color-coral-* from tokens/semantic/color.json, or from your own project’s @layer tokens override. Keeping H constant across the steps and varying L and C is what makes a scale read as one hue.