A palette from a generator is a set of colors that look good together as swatches. An interface needs something else: a background, a few surface levels, borders, two or three text colors, one color for actions, and a set of states for success, warning, and error. Most of those pixels are neutral. The work is deciding which palette color does which job, what to add, and which pairs must meet a contrast ratio.
This article uses a real palette from GradientDeck's curated collection as the running example: #106ee8 (blue), #0fc1a1 (teal), #90e0ab (light green), and #cbffce (pale green). All contrast ratios below were calculated with the WCAG 2 relative luminance formula.
Sort the palette by job
Look at each swatch's lightness and saturation, not its hue. A saturated color of medium lightness can carry an action. A very light, low-saturation color works as a tinted background. A very dark color can be text. In the example:
- #106ee8 is the only color dark enough to hold white text (4.75:1), so it becomes the primary action color.
- #0fc1a1 is bright, but white text on it measures only 2.29:1. Dark text on it measures 8.17:1. It can be an accent with dark text, or a highlight that never carries text.
- #90e0ab and #cbffce are too light for text or buttons. They can be subtle backgrounds, for example the fill behind a success message.
- Nothing in the palette is a neutral, a red, or an amber. You will need to add those.
This is normal. Decorative palettes rarely include the grays that cover most of a screen, and almost never include a usable error red. Treat the palette as your brand colors and build the rest around them.
Build the neutrals first
Backgrounds, cards, dividers, and body text are neutral, so the neutral scale decides most of how the interface looks. Pure grays can look cold next to a saturated brand color. A common fix is to tint the grays slightly toward the primary hue. In the example the neutrals lean blue. You need fewer steps than you might think:
- Canvas: the page background (#f6f8fb).
- Surface: cards and panels (#ffffff), plus one sunken step for wells and table stripes (#eef2f7).
- Border: a decorative divider (#c9d2de) and a stronger border for inputs and controls (#7b889a).
- Text: primary text (#0f1a2a) and muted text for secondary information (#526075).
The two border tokens exist for a reason. WCAG 2's Non-text Contrast criterion (1.4.11) requires 3:1 against adjacent colors for visual information needed to identify a control, such as an input whose only visible boundary is its border. The light divider measures 1.53:1 on white, which is fine for a line between list items but not for a text field. #7b889a measures 3.60:1 on white and 3.39:1 on the canvas.
Primary and accent
The primary color marks what can be clicked and what is selected: main buttons, links, focus rings, active tabs, and checked controls. One color needs several tokens around it, because a button has states and links sit on more than one background.
Testing these tokens turned up a problem. #106ee8 with white text passes AA at 4.75:1. But as link text on the #f6f8fb canvas it measures 4.47:1, just under the 4.5:1 that WCAG 1.4.3 asks for normal-size text. On a white card the same link passes. The fix is a separate, darker link and hover color: #0b5cc7 measures 5.86:1 on the canvas and 6.23:1 with white text. Tinting the background only slightly was enough to push the brand color below the threshold, so check text colors on every background they appear on.
Use the accent sparingly: a badge, a chart series, a highlight in an illustration. If the accent appears as often as the primary, people can't tell which one means clickable.
Semantic colors
Success, warning, danger, and info colors carry meaning that users already expect, so they should look like green, amber, red, and blue even if those hues are not in your palette. You can shift them slightly toward your palette. Here the success color is a dark version of the teal (#0a7d68), so it matches the brand and still reads as green. Each state needs three tokens: a text or icon color, a subtle background, and usually a border.
Pair the colors with an icon or a word. Red-green color vision deficiencies are the most common kind, and a red border with no message gives screen reader users nothing. An error state should still be clear in grayscale.
Name tokens by role, not by color
Use two layers. Primitive tokens hold raw values. Semantic tokens say where a color is used and point to primitives. Components only use semantic tokens. Then the dark theme can swap values without touching a single component, and a later brand change happens in one file.
:root {
color-scheme: light;
/* Surfaces and lines */
--color-bg: #f6f8fb;
--color-surface: #ffffff;
--color-surface-sunken: #eef2f7;
--color-border: #c9d2de; /* decorative dividers */
--color-border-strong: #7b889a; /* inputs, controls: 3:1 or more */
/* Text */
--color-text: #0f1a2a;
--color-text-muted: #526075;
/* Actions */
--color-primary: #106ee8;
--color-primary-hover: #0b5cc7;
--color-on-primary: #ffffff;
--color-link: #0b5cc7;
--color-focus: #106ee8;
--color-accent: #0fc1a1;
--color-on-accent: #0b1220;
/* States */
--color-success: #0a7d68;
--color-success-subtle: #cbffce;
--color-danger: #b42318;
--color-danger-subtle: #fef3f2;
}
.button-primary {
background: var(--color-primary);
color: var(--color-on-primary);
}
.button-primary:hover { background: var(--color-primary-hover); }
.input {
background: var(--color-surface);
border: 1px solid var(--color-border-strong);
color: var(--color-text);
}Pairing every fill token with an on- token (on-primary, on-accent) makes the text color for each background a decision you make once. Developers then don't have to pick white or black by eye each time.
Light and dark themes
A dark theme is not an inverted light theme. Three things change. Surfaces get lighter as they rise, because a shadow barely shows on a dark page. Saturated colors that worked on white look harsh and heavy on near-black, so use a lighter, slightly softer version of the primary. And the on- colors may flip: the light-blue primary below takes dark text.
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
--color-bg: #0b1018;
--color-surface: #131b26;
--color-surface-sunken: #0b1018;
--color-border: #3b4a5e;
--color-border-strong: #64738a; /* 3.60:1 on surface */
--color-text: #e6edf6; /* 14.69:1 on surface */
--color-text-muted: #93a1b5; /* 6.60:1 on surface */
--color-primary: #5c9bf0;
--color-primary-hover: #7db0f4;
--color-on-primary: #0b1018; /* 6.72:1 */
--color-link: #5c9bf0; /* 6.10:1 on surface */
--color-danger: #f97066; /* 6.22:1 on surface */
}
}color-scheme tells the browser to draw form controls, scrollbars, and the default canvas to match, so a native select menu does not stay white in the dark theme. If your site has a manual theme switch, apply the same overrides to a [data-theme="dark"] selector as well.
Using the tokens in Tailwind CSS v4
Because the tokens above already use Tailwind's --color-* namespace, Tailwind can generate utilities from them directly. Move the light values into @theme and keep the dark overrides on :root as they are. Utilities such as bg-surface compile to var(--color-surface), so they follow the override automatically. Your own unlayered :root rules take precedence over the theme layer.
@import "tailwindcss";
@theme {
--color-bg: #f6f8fb;
--color-surface: #ffffff;
--color-text: #0f1a2a;
--color-text-muted: #526075;
--color-primary: #106ee8;
--color-on-primary: #ffffff;
/* ...the rest of the light tokens */
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--color-surface: #131b26;
/* ...the dark overrides from above */
}
}
/* <button class="bg-primary text-on-primary"> <p class="text-text-muted"> */The doubled word in text-text-muted is the cost of the --color-text name; some teams use --color-fg and --color-fg-muted to avoid it. If you prefer to keep the tokens outside @theme and point theme variables at them (for example --color-surface: var(--surface)), Tailwind's documentation says to use @theme inline, so utilities resolve the variable where they are used rather than on :root.
Check contrast for every pair you actually use
Checking each color against white is not enough. List the pairs that meet on screen and test those: text and muted text on canvas, surface, and sunken surface; on-primary on primary and on its hover state; links on every background they appear on; state text on its subtle background; control borders against their surroundings. The targets in WCAG 2 AA are 4.5:1 for normal text, 3:1 for large text (18pt, about 24px, or 14pt bold, about 18.7px), and 3:1 for component boundaries and icons that carry meaning.
A small script keeps this repeatable. It runs in Node or a browser console and uses the WCAG 2 formula:
function luminance(hex) {
const [r, g, b] = [1, 3, 5].map((i) => {
const c = parseInt(hex.slice(i, i + 2), 16) / 255;
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function contrast(a, b) {
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
}
const pairs = [
["text", "#0f1a2a", "#f6f8fb", 4.5],
["muted on sunken", "#526075", "#eef2f7", 4.5],
["white on primary", "#ffffff", "#106ee8", 4.5],
["link on canvas", "#106ee8", "#f6f8fb", 4.5], // 4.47: fails
["input border", "#7b889a", "#ffffff", 3],
];
for (const [name, fg, bg, min] of pairs) {
const ratio = contrast(fg, bg);
console.log(name.padEnd(18), ratio.toFixed(2), ratio >= min ? "ok" : "FAIL");
}For what to measure when backgrounds are images or gradients, see Contrast, in context. For building lighter and darker steps of a color in a perceptual space, see the OKLCH article in this Journal.
Common mistakes
- Using every palette color at equal weight. The interface becomes noisy and nothing stands out as clickable.
- Using the brand color for text because it is the brand color. Check it on every background first; this example failed on a canvas only slightly off-white.
- Taking the error red from the palette's warmest color. A coral or orange can read as a warning or as decoration.
- Naming tokens after their value (--blue, --light-gray). When the dark theme changes --light-gray to a dark color, the name stops making sense.
- Checking contrast only in the light theme. Dark themes often fail on muted text and disabled states.
Starting from the GradientDeck palette generator
The GradientDeck color palette generator picks a palette from its curated collection each time you select Generate or press G. Selecting a color copies its HEX code; the full-screen view also copies RGB and HSL. Export offers CSS custom properties (a :root block with --color-1, --color-2, and so on, ready to rename to roles like the ones above), CSS and Tailwind gradients, a mesh gradient, a PNG, and an SVG. The glasses menu previews the palette as it might look with one of eight types of color vision deficiency, and View variations shows lighter and darker steps of each color, useful for finding hover and subtle-background shades.
Visualize opens the palette in the palette visualizer, which applies it to sample interfaces. Text and background colors there are derived from the primary color, and the next colors become secondary and accent, so it gives a quick first look at roles before you write tokens. It does not replace checking contrast on your own pairs.
References
W3C: Understanding SC 1.4.3 Contrast (Minimum)
W3C: Understanding SC 1.4.11 Non-text Contrast
