CSE 134B Web Client Languages
  1. Presentation
  2. Color
  3. Choosing and pairing

Presentation · Color 05

Choosing and pairing

A usable palette is a few hues, each grown into a scale of lighter and darker steps, then given jobs to do. Harmony helps pick the hues, OKLCH makes the scales even, and tokens make the jobs stick. This page is about making those choices, explaining them to the people you build with, and writing them down so the CSS can keep them.

The Fonts section's Choosing and pairing argued that most typography decisions are about fit, restraint and consistency rather than beauty. Color works the same way. This page starts with the words designers use, then turns to the decisions a developer shares: which hues go together, how much of each, how a hue becomes a scale and a scale becomes named roles, and how to color data for every reader. Page 04, Deriving color, taught the CSS functions that compute one color from another; this page uses them.

1. The words for color

A conversation about color goes better when everyone uses the same words, and the words come in three groups.

Three dimensions

  • Hue is which color it is: red, orange, green, blue. It is an angle around a wheel, and it is what most people mean when they say "color".
  • Chroma is how colorful it is, from gray at zero to as vivid as the color can get. Designers often say saturation, which is a close relative: saturation is colorfulness judged against the color's own brightness; chroma is colorfulness judged against a white in the same light, so it does not change with the color's lightness.
  • Lightness is how light or dark it looks, from black to white. Painters and older color systems call it value. It is the dimension that matters most for reading, because contrast is a difference in lightness, and the eye judges it unreliably across hues: a vivid color can look lighter than its measured luminance.

These are exactly the three channels of oklch(), which is why page 03, Color in CSS, made it the section's default notation: oklch(55% 0.15 45) reads as "middle lightness, quite colorful, orange", and each number can be changed without disturbing the other two.

Tint, shade and tone

Three old words from paint describe the most common changes to a color:

  • A tint is a color mixed with white: lighter and, as it nears white, less colorful. Pastels are tints.
  • A shade is a color mixed with black: darker, and less colorful as it nears black.
  • A tone is a color mixed with gray: duller, at about the same lightness. Muted, "dusty" and "earthy" colors are tones.

In oklch() a tint raises L, a shade lowers it, and a tone lowers C, and in each case the chroma has to come down as the color approaches white or black, because there is less room for color there. Page 04, Deriving color, showed the CSS for all three: color-mix() toward white, black or gray, or relative color syntax changing one channel. The words are worth keeping because designers use them, and because "a lighter tint of the brand" is a clearer request than "change the hex".

Warm and cool

Reds, oranges and yellows are called warm, blues, blue-greens and violets cool; greens and purples sit near the border and lean either way. The words describe associations, and associations are cultural (page 09, Color across cultures). Still, warm colors are said to feel closer and more urgent, which is one reason calls to action and warnings are so often orange or red; cool colors tend to recede, which is why so many backgrounds and interfaces lean blue. A neutral can be warm or cool too, a paper white or a blue-gray, and that small lean does a great deal for the feel of a page, as section 8 shows.

2. Color is relative

No color is seen alone. The eye judges every color against its neighbors, and the same swatch can look lighter, darker, warmer or duller depending on what surrounds it. The effect is called simultaneous contrast, and it is not an illusion that careful people avoid; it is how vision works, and everyone experiences it.

Simultaneous contrast: the same color on two backgroundsFour panels. The first two each hold the same middle gray square, oklch(60% 0 0). On a dark navy background, oklch(28% 0.06 260), it looks lighter; on a pale yellow background, oklch(92% 0.07 95), it looks darker. The last two each hold the same orange square, oklch(65% 0.13 55). On an orange background, oklch(72% 0.12 60), it looks dull and brownish; on a blue background, oklch(45% 0.1 255), it looks vivid. The squares are identical in each pair.The same grayThe same orangegray on dark navylooks lightergray on pale yellowlooks darkerorange on orangelooks dullorange on bluelooks vividboth squares: oklch(60% 0 0)both squares: oklch(65% 0.13 55)
Each pair of squares is one color. Surroundings push a color away from themselves: lighter on dark, darker on light, duller beside its own hue, more vivid beside its complement.

The painter and teacher Josef Albers built a whole course on this. His Interaction of Color, first published by Yale University Press in 1963 and now also a complete digital edition, is a set of exercises in which students make one color look like two and two colors look like one, using nothing but what surrounds them. Its lesson for a developer is practical: a color chosen on a white artboard will look different on the dark surface of a dark theme, beside a photograph, or next to the other colors in the palette. Judge a color where it will be used.

Relativity has limits that matter for access. Simultaneous contrast changes how a color looks, but WCAG contrast ratios are computed from luminance alone, and they do not change with the surroundings. A gray that looks lighter on navy still has the same measured contrast with the text on it. So the eye decides whether a palette feels right, and the numbers decide whether it can be read. This section needs both, which is why every demo in it prints its ratios.

3. Harmony as hue arithmetic

Color harmony is the old idea that some combinations of hues sit well together, described as positions on a color wheel. The classic schemes are few:

  • Monochromatic: one hue in several lightnesses and chromas. The safest scheme, and the basis of every tonal scale in section 6.
  • Analogous: neighbors, about 30 degrees apart. Calm and cohesive, like autumn leaves or a sea.
  • Complementary: opposites, 180 degrees apart. The strongest contrast of hue, and the hardest to use in equal amounts.
  • Split-complementary: a hue and the two neighbors of its complement, at 150 and 210 degrees. Nearly as lively as the complement, and easier to balance.
  • Triadic: three hues 120 degrees apart. Lively, and best when one of the three leads.

In oklch() each scheme is arithmetic on one token. Keep lightness and chroma, add degrees to the hue:

:root { --base: oklch(55.14% 0.1471 44.6); /* Terra Outfitters' orange */ /* Plain values for browsers without relative color */ --complement: oklch(55.14% 0.10 224.6); --analog-cool: oklch(55.14% 0.1471 14.6); --analog-warm: oklch(55.14% 0.11 74.6); } @supports (color: oklch(from red l c h)) { :root { --complement: oklch(from var(--base) l c calc(h + 180)); --analog-cool: oklch(from var(--base) l c calc(h - 30)); --analog-warm: oklch(from var(--base) l c calc(h + 30)); --split-1: oklch(from var(--base) l c calc(h + 150)); --split-2: oklch(from var(--base) l c calc(h + 210)); --triad-1: oklch(from var(--base) l c calc(h + 120)); --triad-2: oklch(from var(--base) l c calc(h + 240)); } }

Two things make this better than the same arithmetic in hsl(). Because OKLCH lightness tracks perceived lightness, every member of the scheme looks about as light as the base, so none of them jumps forward or sinks. And because the scheme is computed from one token, changing --base changes the whole scheme, which is the point of deriving colors (page 04, Deriving color, explains relative color syntax and why the plain values come first and the derived ones sit inside @supports).

One caution comes with constant chroma. A chroma that fits comfortably at one hue may not fit at another, because the sRGB gamut is lopsided: it holds vivid oranges and blues at middle lightness, but much less chroma in the cyans. Terra Outfitters' orange has a chroma of 0.147; turned 180 degrees to a blue-cyan at the same lightness, that chroma is outside sRGB, and the browser has to bring it in. Chrome clips it, which shifts its lightness and hue (page 03, Colors no screen can show). The plain fallbacks above lower the chroma to what sRGB holds at that lightness, 0.10 for the complement and 0.11 for the warm neighbor. The derived values inside @supports keep the base's 0.147, so they still need a gamut check, or a chroma of their own. A scheme is a starting point, and each member still needs a look and a check.

4. Which wheel?

Every harmony scheme assumes a wheel, and there is more than one. They put the complement of a color in different places, so "the complementary color" is only meaningful once you have said which wheel you mean.

  • The painter's wheel has red, yellow and blue as its primaries, the RYB model taught in art classes. It describes, roughly, how pigments mix. On it, red's complement is green, yellow's is purple and blue's is orange. It has no single agreed formula; it is a teaching model.
  • The RGB wheel, which hsl() and most color pickers use, has red, green and blue as primaries 120 degrees apart, because screens mix light. On it, red's complement is cyan, green's is magenta and blue's is yellow. Its hue angle is arithmetic on the RGB cube, so equal angles do not look equally far apart.
  • The OKLCH hue circle spaces hues by how they look. Red sits near 29, yellow near 110, green near 142 and blue near 264, so the gaps between primaries are uneven by design. Red's complement lands near 209, a blue-cyan, and blue's near 84, a yellow-orange.
One red, three wheels, three complementsThree columns, each starting from the same red, oklch(60% 0.15 29), which is hsl(6.5 54.9% 53.9%). Painter's red-yellow-blue wheel: the complement is green, shown as oklch(55% 0.12 145); there is no formula, it is a mixing model. HSL wheel: hue 6.5 plus 180 is 186.5, the color hsl(186.5 54.9% 53.9%), which is oklch(73.6% 0.104 206.4): a cyan, and 13.6 points lighter than the red although HSL lightness did not change. OKLCH hue circle: hue 29 plus 180 is 209, at the same lightness of 60%; at chroma 0.15 that color is outside sRGB, so it is shown at chroma 0.1, oklch(60% 0.1 209), a teal. Below: blue's complement is orange on the painter's wheel, yellow on the RGB wheel, and a yellow-orange near hue 84 on the OKLCH circle.Painter's wheel (RYB)RGB wheel (hsl)OKLCH hue circleredgreenhue 6.5hue 186.5: cyanhue 29hue 209: tealno formula: a modelof mixing pigmentssame HSL lightness, butOKLCH L jumps 60% → 73.6%same L 60%; chroma 0.15 isoutside sRGB, shown at 0.1Blue's complement:orangeyellowyellow-orange, hue 84264 + 180 − 360 = 84
"The complement" depends on the wheel. The RGB wheel also changes how light the result looks; the OKLCH circle keeps lightness, but may ask for more chroma than a screen can show.

None of these wheels is the true one. Each is a model built for a purpose: mixing paint, driving a screen, predicting perception. What they share is the useful part of harmony, a disciplined way to pick a small number of hues that relate to each other, rather than one hue per whim. Use OKLCH for the arithmetic because it keeps lightness steady, then judge the result by eye, in place, beside the real content, as Albers would. Tools such as Adobe Color let you explore harmony rules on a wheel; check which wheel a tool uses before copying its "complement" into a style sheet.

5. Proportion

Which colors you use matters less than how much of each. A complementary pair in equal amounts fights; the same pair as a large field and a small accent sings. A common rule of thumb for interfaces is 60-30-10: about 60 percent of the surface in a dominant color, usually a neutral; about 30 percent in a secondary color, often the brand; and about 10 percent in an accent.

Treat the numbers as a tendency, not a law. Nobody measures the pixels, and a data-heavy dashboard or a photo-led shop will land somewhere else. What the rule captures is a hierarchy that matters for usability:

  • The dominant color is quiet. Backgrounds and large surfaces are where the eye rests and where text sits, so they should be calm, usually near white or near black, with plenty of contrast for text.
  • The secondary color gives identity. Headers, navigation and section backgrounds carry the brand without asking for action.
  • The accent has one job. Reserve the loudest color for what you want people to do, usually the main call to action, and perhaps the focus ring. An accent used everywhere stops meaning anything, the way bold type does when a whole paragraph is bold.

The Terra Outfitters store shows the hierarchy. On the fixed Trail report, the dominant color is a warm paper, oklch(96.7% 0.0082 91.48); the brand is a deep forest green, oklch(34.15% 0.0522 150.6), used for the header band; and the orange accent, oklch(55.14% 0.1471 44.6), is kept for buttons, the skip link and the focus outline. The orange and the green are 106 degrees apart on the OKLCH circle, nowhere near a strict complement, and the pair works because there is so little orange.

6. Tonal scales

A single brand color is not enough to build an interface. A button needs a hover and a pressed state, a card needs a faint tint of the brand, an alert needs a pale background with dark text in the same hue, and dark mode needs all of it again from the other end. So each hue in the palette becomes a tonal scale: a ramp of 9 to 12 steps from nearly white to nearly black.

A good scale has three properties, and OKLCH makes each of them a number you can set:

  • Even lightness steps. L should fall by similar amounts from step to step, so that "two steps apart" means about the same contrast anywhere on the scale. That is what makes a scale predictable.
  • Chroma that peaks in the middle. Near white and near black there is little room for color, so chroma rises from the light end, peaks around the middle steps, and falls again toward the dark end. A scale that holds one chroma throughout leaves the sRGB gamut at both ends.
  • A steady hue, or a deliberate drift. OKLCH keeps hue stable as lightness changes, so a blue scale stays blue instead of drifting purple as it does in CIELAB. Some designers drift the hue on purpose, warmer in the lights and cooler in the darks, which is a choice rather than an accident.
An eleven-step tonal scale of Terra Outfitters' orange, hue 45, with its lightness and chromaEleven swatches labeled 50 to 950, all at hue 45. Lightness falls evenly: 97, 93, 87, 79, 70, 61, 53, 46, 39, 32 and 25 percent. Chroma rises and falls: 0.012, 0.035, 0.07, 0.115, 0.15, 0.16, 0.143, 0.12, 0.095, 0.07 and 0.045. A line chart below plots the chroma: it climbs from the light end, peaks at step 500, and falls toward the dark end, staying inside sRGB at every step. Steps 600 and darker reach 4.5:1 with white; steps 50 to 400 reach 4.5:1 or more with step 950 text.50100200300400500600700800900950L 9793877970615346393225C .012.035.07.115.15.16.143.12.095.07.045chroma peaks mid-scaletext on white: 600 and darker (5.6:1 and up)step-950 text: on 50 to 400
A tonal scale built in OKLCH: lightness falls in even steps, chroma rises to a peak and falls back so every step stays inside sRGB, and the hue holds at 45.

Step numbers such as 50 to 950 are only a convention, common in CSS frameworks; they mean nothing in themselves. What gives a scale its value is a promise about contrast between steps. Stripe's designers, Daryl Koopersmith and Wilson Miner, described in "Designing accessible color systems" (2019) how they rebuilt Stripe's palette with a tool based on CIELAB so that "any two colors are guaranteed to have sufficient contrast for small text if they are at least five levels apart." A rule like that turns a contrast check into arithmetic a designer can do in their head. The ramp above shows the same idea: text in step 950 reads on steps 50 to 400, and steps 600 and darker read as text on white. (Page 04's ramp builder spaces its lightness differently, so there white text passes from step 500; the step where text starts to pass belongs to each scale, not to its number.)

Three published systems, compared

The best way to see the choices is to compare systems that made them differently. Three are worth knowing, and each publishes how it works:

How three color systems build their scales (from each project's documentation, October 2026)
SystemHow the scale is madeStepsWhat a step promises
Radix ColorsHand-tuned scales, published with separate light and dark versions of each hue12A job for each step: 1 and 2 app backgrounds; 3 to 5 component backgrounds (normal, hover, pressed or selected); 6 to 8 borders and focus rings; 9 and 10 solid backgrounds, 9 being the purest, most chromatic step; 11 and 12 text. Steps 11 and 12 are guaranteed to reach set APCA levels (Lc 60 and Lc 90) on step 2.
Material 3Generated: one source color gives five key colors (primary, secondary, tertiary, neutral, neutral variant), each grown into a tonal palette in Google's HCT space, whose "tone" is CIELAB lightness13 standard tones: 0 to 100 in tens, plus 95 and 99; extra tones such as 98 serve surfacesRoles take tones by rule: in a light scheme, primary is tone 40 of the primary palette and on primary is tone 100. A difference in tone stands for a contrast ratio, so the rules guarantee contrast; Material's color system offers standard, medium and high contrast levels.
LeonardoGenerated from contrast targets: Adobe's open-source tool takes key colors, a background color and a list of target contrast ratios, and finds the colors that meet them, interpolating in a color space you chooseAs many as you ask forEach step is defined by its contrast with the background, so a theme regenerated for dark mode, or at higher contrast, keeps the same promises

The three differ in where the knowledge lives: in Radix's step numbers (step 11 is "low-contrast text"), in Material's algorithm (one wallpaper color yields a whole accessible scheme), or in Leonardo's targets (the designer states the contrast, the tool does the color science). Radix states its guarantees in APCA, the contrast method once proposed for WCAG 3, Material works from WCAG 2 ratios, and Leonardo offers either WCAG 2 or APCA; page 08, Color for people, explains why WCAG 2's ratio is still the one that applies to conformance.

Page 04's ramp builder takes one color and produces an eleven-step scale with relative color syntax, exported as custom properties. Build a scale there, then check it the way the systems above do: are the lightness steps even, does the chroma stay in gamut, and which pairs of steps reach 4.5:1? Page 03 noted that this site's own navy scale, converted from a designer's hex, has uneven steps and no 400; Huetone is a free tool for seeing and fixing exactly that kind of unevenness across several hues at once.

7. Roles over values

A scale is a set of values. An interface needs roles: the color of the page, of body text, of muted text, of a border, of the main action, of an error. The habit that keeps a palette maintainable is to separate the two, in tiers:

  1. Primitive tokens name values by hue and step: --clay-600, --navy-950. They say what the color is and nothing about where it goes.
  2. Semantic tokens name roles: --surface, --text, --text-muted, --accent, --danger. Each points at a primitive, and in a two-scheme design at a different primitive for light and for dark.
  3. Component tokens name a role inside one component: --button-bg, --cb-string. Each points at a semantic token, so a component can be retuned without touching the rest.

The rule that makes the tiers work is one-way references: components use semantic tokens, semantic tokens use primitives, and nothing outside the token file uses a primitive directly. Then a rebrand is a change to primitives, a dark theme is a change to which primitives the roles point at, and a contrast fix is a change to one role, each in one place. CSS Values page introduced custom properties as tokens; here is what the tiers look like in a real file.

This site's theme, in tiers

This site's style sheet, src/assets/css/powell-theme.css, is built exactly this way. Its primitives are navy, gold and gray scales, each an oklch() value with the designer's original hex in a comment (page 03 told how they were converted). Its roles are VB's --color-* tokens, each set with light-dark() so one declaration covers both schemes. And a few component tokens sit on top: the code samples on this page color their strings with --cb-string.

/* Primitives: values, named by hue and step */ --gold-800: oklch(52.21% 0.1082 79.73) /* #8A6100 */; --gold-500: oklch(86.72% 0.1773 90.85) /* #FFCD00 */; /* Semantic: a role, a different primitive per scheme. gold-500 is 1.5:1 on white, so light mode uses the deeper gold. */ --color-accent-text: light-dark(var(--gold-800), var(--gold-500)); /* Component: code-block strings take the role, not the value */ --cb-string: var(--color-accent-text);
Primitive, semantic and component tokens in this site's themeThree boxes from left to right, joined by arrows that point from each token to the one it uses. Primitive: --gold-800, oklch(52.21% 0.1082 79.73), and --gold-500, oklch(86.72% 0.1773 90.85), named by hue and step. Semantic: --color-accent-text, light-dark(var(--gold-800), var(--gold-500)), named by role, one primitive per scheme. Component: --cb-string, var(--color-accent-text), the color of strings in code samples. Below, a note: a rebrand changes primitives; a dark theme changes which primitive a role points at; a component is retuned by changing its own token. The same alias in the W3C Design Tokens format is written "{color.gold.800}".primitive--gold-800oklch(52.21% 0.1082 79.73)--gold-500oklch(86.72% 0.1773 90.85)semantic--color-accent-textlight-dark(var(--gold-800),var(--gold-500))a role; one primitive per schemecomponent--cb-stringvar(--color-accent-text)strings in codeRebrand: change primitives. Dark theme: change which primitive a role uses.Retune one component: change its own token. Arrows point to what each token uses.Design Tokens format alias: "$value": "{color.gold.800}"
Tokens reference one way, from component to role to value, so each kind of change happens in one place.

The comment in the excerpt is the reason for the middle tier. Gold is this site's signal color, and --gold-500 is the bright gold of the logo; as text on white it measures 1.5:1, far below the 4.5:1 that body-size text needs. So the role "accent text" points at the deeper --gold-800 in light mode, 5.5:1 on white, and at the bright gold only in dark mode, where it reaches 11:1 on navy. Components never see the difference. And test/theme-contrast.test.js checks those ratios from the oklch() values on every test run, so a later edit to either gold cannot quietly break them.

Tokens outside CSS

Custom properties hold tokens in a style sheet, but a palette also lives in design tools, in native apps and in documentation. The W3C Design Tokens Community Group published three Final Community Group Reports on 28 October 2025, versioned 2025.10: the Format Module, the Color Module and the Resolver Module, which handles sets of values such as light and dark modes. They are the group's stable format for moving tokens between tools. Community group reports are not W3C Standards; their weight comes from the design tools and token pipelines that adopt them.

A token file is JSON, with the media type application/design-tokens+json and the recommended extension .tokens or .tokens.json. Each token has a $value, usually a $type, and optionally a $description; an alias is written as a path in curly braces. The Color Module gives a color as a color space and its components, and it supports oklch among fourteen spaces, with an optional six-digit hex fallback:

{ "color": { "gold": { "800": { "$type": "color", "$value": { "colorSpace": "oklch", "components": [0.5221, 0.1082, 79.73], "hex": "#8a6100" } } }, "accent-text": { "$type": "color", "$value": "{color.gold.800}", "$description": "Accent as text; 5.5:1 on white" } } }

Notice that lightness is a number from 0 to 1 here, not a percentage, as in a computed oklch() value. For a course project a hand-written CSS file is enough; the format matters when a design tool is the source of truth and the CSS is generated from it.

8. Neutrals do most of the work

Count the pixels of almost any interface and most of them are neutral: backgrounds, surfaces, borders, body text, muted text, disabled controls. The brand colors get the attention, but the grays set the mood, and a pure gray, chroma zero, can make a warm brand look cold and a careful palette look unfinished.

The fix is to tint the neutrals toward the brand: give the grays the brand's hue, or a neighbor of it, at a very low chroma, around 0.01 to 0.02 in oklch(). That is too little to read as a color, but enough that the grays belong to the palette. Both examples in this section do it:

  • This site's grays lean toward its navy. In powell-theme.css, --gray-700 is oklch(41.4% 0.0226 258.37) and --gray-300 is oklch(84.96% 0.0111 256.7): hues from about 248 to 261, the same family as --navy-800 at 259, with chroma from about 0.002 at the lightest step to 0.023 in the middle. The designer chose them as hex; converting to oklch() made the intent visible.
  • Terra Outfitters' neutrals lean toward its forest green and paper. On the fixed Trail report, the page surface is oklch(96.7% 0.0082 91.48), a warm paper, and body text is oklch(27.08% 0.0252 144.84), a near-black with a little of the brand's green in it. The text measures 13.5:1 on the surface.
:root { --brand-h: 150; /* the forest green's hue */ --neutral-50: oklch(97% 0.008 var(--brand-h)); --neutral-200: oklch(90% 0.012 var(--brand-h)); --neutral-500: oklch(62% 0.018 var(--brand-h)); --neutral-700: oklch(42% 0.02 var(--brand-h)); --neutral-900: oklch(25% 0.02 var(--brand-h)); }

Keep the chroma low and check it in both schemes. A tint that is pleasant on a white background can look muddy in a large dark surface, where the eye is more sensitive to small color casts, and a neutral that leans too far stops being neutral and starts competing with the accent.

9. Status colors

Most interfaces need four status colors: success, warning, danger and information. Convention makes them green, amber or yellow, red and blue, and convention is worth following because readers bring it with them. But these four roles carry meaning, and meaning cannot rest on hue alone. About 1 in 12 men has a color vision deficiency, according to the US National Eye Institute, and the most common kind makes red and green hard to tell apart: exactly the pair that means "failed" and "passed". Two habits follow.

  • Status always travels with a word or an icon. "Error: enter an email address" with a warning sign, not a red border; "In stock" in text, not a green dot. This is WCAG's Use of Color criterion (1.4.1), and page 08, Color for people, covers it in depth. The broken stores break it twice: the inaccessible store's SIN #8 shows stock by a colored dot, and the discolored Trail report's SIN #8 marks form errors only with a red border. The fixed Trail report writes the error in words, after an icon, in a red that reaches 6.63:1.
  • Where status is a fill, vary lightness as well as hue. Badges, chart markers, map pins and progress segments are often read at a glance and without a word beside them. If success is a light green and danger a dark red, the two stay apart for a reader who cannot see their hue, and in grayscale print.

The two habits pull in different directions for text, and it is worth seeing why. This site's status text colors in light mode are a green at oklch(51.54% …), a red at 54.38%, a blue at 50.16% and a gold at 52.21%. They differ by hue and hardly at all by lightness, and that is deliberate: each is used as text on white, so each must be dark enough to reach 4.5:1, and the four measure between 5.3:1 and 6.1:1. Text colors that must all pass end up close in lightness, which is exactly why the word cannot be optional. The color confirms the message; the word is the message.

:root { --danger-text: light-dark(oklch(50% 0.19 27), oklch(76% 0.14 25)); --danger-subtle: light-dark(oklch(94% 0.03 27), oklch(26% 0.05 27)); } .alert[data-status="danger"] { color: var(--danger-text); background-color: var(--danger-subtle); border-inline-start: 0.25rem solid currentColor; } .alert[data-status="danger"]::before { content: "⚠\FE0E Error: " / "Error: "; /* the word, and alt text for the icon */ }

Status colors also collide with the brand. A red brand makes every "danger" message look like marketing, and every sale banner look like an error; a green brand blurs with "success". Where the brand owns one of the status hues, shift the status color a little in hue and lightness so the two are distinguishable, and lean even harder on the words. Status meanings are also not universal: red means up, not down, on stock tickers in mainland China, which is the Trail report's SIN #10 and the subject of page 09, Color across cultures.

10. Data palettes

Charts are where color carries the most meaning and fails most often. A data palette has to do something an interface palette does not: make values or categories readable from color, quickly, for every reader, often in a small space. The kind of palette depends on the kind of data, and there are three kinds.

  • Categorical (also called qualitative) palettes are for things that are different but not ordered: product lines, countries, browsers. They need hues that are distinct from each other and roughly equal in visual weight, so no category looks more important. Keep the number small: past about seven or eight categories, colors become too similar to tell apart, and the chart needs direct labels, grouping or a different form.
  • Sequential palettes are for ordered values from low to high: population, temperature, visitors. They change mainly in lightness, from light to dark or dark to light, sometimes with a shift of hue along the way. Lightness is what the eye reads as "more", so the order survives in grayscale and for color-blind readers.
  • Diverging palettes are for values above and below a meaningful middle: change from last year, a vote margin, a temperature anomaly. They are two sequential ramps in different hues that meet at a neutral midpoint, with matching lightness steps on each side so that +10 and −10 look equally strong.

Why viridis

For a long time the default continuous palette in scientific software was a rainbow, such as "jet", long the default in MATLAB and in Matplotlib. Rainbows look lively and read badly. Their lightness rises and falls along the scale, so bright yellow and cyan bands in the middle look like features in the data when they are only features of the palette, and in grayscale, or for a reader with a color vision deficiency, the order of the values is lost. Matplotlib's guide to choosing colormaps shows jet's lightness swinging up and down, and makes the case that the eye perceives "changes in the lightness parameter as changes in the data much better than, for example, changes in hue".

Viridis, designed by Stéfan van der Walt and Nathaniel Smith from a starting point by Eric Firing, replaced jet as Matplotlib's default in version 2.0. It runs from dark purple through blue and green to yellow, with lightness rising steadily the whole way. Its designers set out to make it perceptually uniform both in color and when converted to black and white, and friendly to common forms of color blindness. When a chart needs a continuous scale and has no reason to choose otherwise, viridis, its siblings magma, inferno and plasma, or cividis, a later variant tuned for color vision deficiency, is a sound choice.

ColorBrewer and Okabe–Ito

Two older resources are still the first places to look for tested palettes. ColorBrewer, by Cynthia Brewer and Mark Harrower at Pennsylvania State University, was made for maps and offers sequential, diverging and qualitative schemes in a range of class counts, with filters for schemes that are colorblind safe, print friendly and photocopy safe. Its sequential and diverging schemes are the classic examples of the lightness discipline described above. For categorical colors, Masataka Okabe and Kei Ito's Color Universal Design guide (first published in 2002, revised in 2008), written by two scientists who are themselves protanopes, proposes a palette of distinguishable colors: an orange, a sky blue, a bluish green, a yellow, a blue, a vermilion and a reddish purple, with black. Those eight are among the most widely used categorical colors in science.

The store's chart

The broken store has a chart that fails all of this. The discolored Trail report marks it as SIN #5: rain shells and sun hats sold by season, drawn as two lines of the same width with the same round markers, one red and one green, named only in a legend of colored squares. To a reader with red-green color blindness the two lines are nearly one color, and the only key is two squares of that color.

The fixed Trail report marks the repair as COLOR FIX #5, and it puts color third:

  1. Direct labels. Each line is named at its end, "Rain shells" and "Sun hats", so there is no legend to decode and nothing to match by color.
  2. Line and marker patterns. Rain shells is a solid line with round markers, sun hats a dashed line with square ones, so the lines differ in shape as well as color. They stay two lines in grayscale print, and under forced colors, where the chart draws them in two system colors, they keep their dashes and markers.
  3. Hues that differ in lightness. Only then, color: a blue, oklch(48% 0.15 255), and an orange, oklch(58% 0.14 60), ten points apart in lightness and on the blue-orange axis that red-green color blindness leaves intact.
:root { /* Chart series: hues far apart and lightness apart. */ --series-rain: light-dark(oklch(48% 0.15 255), oklch(78% 0.1 255)); --series-sun: light-dark(oklch(58% 0.14 60), oklch(82% 0.14 75)); } .chart { color: var(--text); & text { fill: currentColor; } & .rain { stroke: var(--series-rain); fill: var(--series-rain); } & .sun { stroke: var(--series-sun); fill: var(--series-sun); } & .sun polyline { stroke-dasharray: 10 6; } & .label { font-weight: bold; } }

Non-text marks that a reader needs, such as chart lines, should also reach 3:1 against their background, WCAG's Non-text Contrast criterion (1.4.11). It is one more reason to choose data colors in OKLCH, where lightness is a number you can read, and to choose separate values for dark mode, as both tokens above do.

Simulations are a check, not a substitute: they model the common deficiencies at full strength, while real color vision varies, and they say nothing about low vision or glare. The habits of this section help in all of those cases; the simulation only confirms they are in place. Page 08, Color for people, goes further, with the Other eyes demo applying the same filters to a whole page.

11. Exercise: a palette for Terra Outfitters

Put the page together by building a palette for the store from scratch, as a single CSS file of tokens with a contrast table beside it.

  1. Choose two hues. An earthy primary, such as the store's forest green at hue 150, and an accent. Compute the accent's candidates with calc(h + 180), calc(h + 150) and calc(h + 210) on the OKLCH circle, compare them with the store's orange at 44.6, and decide by eye which one you would put on a "Shop the sale" button.
  2. Grow each hue into an eleven-step scale, 50 to 950, with even lightness steps and chroma that peaks mid-scale and stays inside sRGB. Use the ramp builder or write the values by hand.
  3. Make a neutral scale tinted toward the primary, chroma between 0.005 and 0.02.
  4. Define semantic tokens with light-dark(): --surface, --raised, --text, --text-muted, --line, --brand, --on-brand, --accent, --on-accent, --link, and the four status roles, each with a text color and a subtle background. Only primitives may hold oklch() values; roles hold var().
  5. Add two chart series tokens that differ in lightness by at least ten points and that reach 3:1 against --surface in both schemes.
  6. Write the contrast table. For every text and background pair your roles allow, in light and in dark, compute the WCAG 2 ratio (the section's contrast.js does it in a few lines) and mark each pair against 4.5:1, or 3:1 for large text and for non-text marks. Any pair that fails is a role pointing at the wrong step.
  7. Apply 60-30-10 to a copy of the Trail report, check it in both schemes and under forced colors, and compare it with the store's own tokens.

The result should be a file a teammate could read without you: primitives grouped by hue, roles that say what they are for, and a table that proves the pairs work.

12. Summary

Choosing and pairing colors, in brief
ConceptKey points
VocabularyHue, chroma (or saturation) and lightness (or value) are the three channels of oklch(). A tint adds white, a shade black, a tone gray. Warm and cool are associations, and they lean neutrals too.
Color is relativeSimultaneous contrast changes how a color looks beside its neighbors (Albers, Interaction of Color, 1963). Judge colors in place; measure contrast with numbers, which do not change with the surroundings.
HarmonyMonochromatic, analogous (±30°), complementary (180°), split-complementary (150° and 210°) and triadic (120°), each as oklch(from var(--base) l c calc(h + N)) behind @supports. Constant chroma can leave sRGB at some hues.
Which wheelRed's complement is green on the painter's RYB wheel, cyan on the RGB wheel, and a teal near hue 209 on the OKLCH circle. HSL arithmetic also swings lightness. Wheels are models; judge the result.
Proportion60-30-10 is a rule of thumb: a quiet dominant neutral, a secondary for identity, an accent kept for one job.
Tonal scales9 to 12 steps, even in lightness, chroma peaking mid-scale, hue steady. Radix assigns a job to each of 12 steps; Material 3 generates tonal palettes in HCT and assigns tones to roles; Leonardo generates steps from target contrast ratios.
Roles over valuesPrimitive tokens feed semantic tokens, which feed component tokens, in one direction only. This site's --gold-800 → --color-accent-text → --cb-string. The Design Tokens Community Group's Format, Color and Resolver modules (2025.10) move tokens between tools as JSON, with oklch supported.
NeutralsMost of an interface is neutral. Tint grays toward the brand at chroma 0.01 to 0.02, and check them in both schemes.
Status colorsFollow the conventions, always add a word or icon, and where status is a fill, vary lightness as well as hue. Text colors that must pass 4.5:1 end up close in lightness, which is why the word is the message.
Data palettesCategorical: distinct hues, seven or eight at most, plus direct labels and patterns. Sequential: one lightness ramp; viridis over rainbows. Diverging: two ramps around a neutral middle. ColorBrewer and Okabe–Ito for tested sets; chart marks need 3:1.
CheckingColor-vision simulations (Machado et al., 2009) are approximations. The fixed store's chart puts labels and line patterns first and color third.