Build a palette that passes every pair
Not every pair in a palette has to pass. Every pair you use has to. Here is how to build a palette where the allowed pairs are obvious and the forbidden ones are hard to reach for.
Open the Color Lab and look at this site's own palette in the matrix. About half the pairs fail. That is not a problem with the palette. It is a problem with the idea that a palette is a bag of colors you can combine freely.
A palette is a set of colors plus a set of rules about which may touch which. Build it that way and the contrast work takes care of itself.
Start with roles, not colors
Before picking a single hex value, list the jobs colors do on your pages.
- Body text on the page background
- Body text on a card or panel background
- Muted text (captions, metadata) on both of those
- Links in body text
- Buttons: label on fill, fill on page
- Borders and dividers against the surfaces they sit on
- Focus ring against every surface a control can sit on
- Status colors (error, success, warning) as text, as icon, and as border
Each job is a pair: a foreground and a background. That list is the real palette. The hex values are an implementation detail.
Pick the surfaces first
Most pairs have a surface as their background. Pick the surfaces (page, card, soft panel, dark band) and keep them few. Every extra surface multiplies the number of pairs you have to check. Three surfaces is comfortable. Six is a maintenance burden.
Then pick a text color that clears 4.5:1 on every surface. On light surfaces that means a dark ink; on the dark band, a near-white. If a single ink cannot cover all your light surfaces, the surfaces are too far apart. Pull them together.
Then the accents, tested as pairs
Each accent color has a job: link, button fill, status. Test it in the job, not in the abstract.
- Link color on the page surface and on the card surface: 4.5:1 for both, since links sit in body text.
- Button fill on the page surface: 3:1, since the fill is a UI boundary, not text. Label on the fill: 4.5:1.
- Status red as text: 4.5:1 on its surface. As a border or icon: 3:1. The same red often fails one and passes the other, which is why many systems carry a text variant and a UI variant of each status color.
The matrix in the lab shows every pair with a band under it: green for text, amber for UI only, red for never. The amber cells are not failures. They are the pairs you are allowed to use for borders and icons but not for words.
Muted text is where it goes wrong
Nearly every palette has a "muted" gray for secondary text, and nearly every one fails on the soft panel surface. The gray was tuned on white. Put it on a pale blue card and it drops under 4.5:1. The lab's default palette shows this in the muted row.
Two fixes. Darken the muted gray until it passes on the darkest light surface you have. Or stop using muted text on panels, and make that a rule.
Write the rules down
The output of this process is not a list of hex values. It is a short table: this foreground on these backgrounds, for this purpose. In tokens, that looks like --text-on-surface, --text-on-panel, --link-on-surface, rather than --gray-600. A name that says where a color may go is a rule a reviewer and an agent can both check.
The lab exports plain --name: #hex tokens. Naming them by job is the step after the export, and it is the step that keeps the palette passing after you have stopped looking at the matrix.
Check it under simulation
Switch the lab's view to deuteranopia and look at the status colors. If error red and success green collapse into the same brown, that is a pair you were relying on color alone to separate. The fix is not a different red. It is a word or an icon, as Color is never the only signal explains.