How to Build an Accessible UI Color Palette
Build a UI palette that passes contrast by design: lightness scales, safe steps for text and borders, semantic colors, and light and dark themes.
By ColorOverflow Editorial TeamUpdated November 13, 20267 min readIntermediate
Most accessibility problems with color aren't caused by bad colors. They're caused by a palette that was never planned for contrast, so every new screen means guessing which pairs are safe. An accessible UI palette solves that up front. It gives each color a range of lighter and darker steps and makes clear which steps can be used for text, which for borders and which only for backgrounds.
This guide walks through building one, using a worked example with every ratio checked. It applies the WCAG contrast levels rather than explaining them, so if you need a refresher on what 4.5:1 and 3:1 mean, read our guide to color contrast and WCAG first.
Read the full guideColor Contrast and WCAG: A Practical GuideStart with roles, not colors
A UI needs far more colors than a brand palette shows. Before choosing values, list the jobs colors have to do:
- Backgrounds and surfaces: the page, cards, panels and subtle highlighted areas.
- Text: body text, secondary text and text on colored buttons.
- Borders and dividers: input outlines, which must be visible, and decorative lines, which can be faint.
- Brand and interactive: buttons, links, focus rings and selected states.
- Semantic: success, warning, error and information.
Each role has its own contrast need. Text needs the most, borders that identify controls need less, and backgrounds mostly need to sit quietly behind everything else.
Build a lightness scale for each color
Give every color, including your neutral, a scale of steps from very light to very dark. A common convention numbers them 50, 100, 200 and so on up to 900 or 950, where 50 is almost white and 950 almost black. The hue stays the same; only lightness and saturation change.
- #EEF2FF
- #C7D2FE
- #A5B4FC
- #818CF8
- #6366F1
- #4F46E5
- #4338CA
- #312E81
- #1E1B4B
Indigo 50, 200, 300, 400, 500, 600, 700, 900 and 950. The light end works for backgrounds, the dark end for text and filled buttons.
- #F8FAFC
- #F1F5F9
- #E2E8F0
- #CBD5E1
- #94A3B8
- #64748B
- #475569
- #334155
- #1E293B
- #0F172A
Slate 50 to 900, a cool gray with a hint of blue that sits well next to the indigo.
What makes a scale accessible is that each step has a predictable contrast against white and against the darkest neutral. Once you know those numbers, you know which steps can be used where, and you can stop checking every pair from scratch.
Find the safe steps
Here is the indigo scale measured against white. Notice how sharply the ratio climbs in the middle of the scale.
- Indigo 300 #A5B4FC: 1.99:1. Background tints and decoration only.
- Indigo 400 #818CF8: 2.98:1. Just short of 3:1, so not even safe for a control border on white.
- Indigo 500 #6366F1: 4.46:1. Enough for large text and borders, but it fails for normal text.
- Indigo 600 #4F46E5: 6.28:1. Safe for normal text, links and filled buttons with white text.
- Indigo 700 #4338CA: 7.90:1. Safe even where you want extra margin, such as small text.
The neutral scale has the same kind of line. On white, slate 400 #94A3B8 is only 2.56:1, while slate 500 #64748B reaches 4.75:1 and slate 600 #475569 reaches 7.57:1. That gives you clear rules:
- Body text: slate 900 or 800 on light backgrounds. Slate 900 #0F172A on slate 50 #F8FAFC is 17.06:1.
- Secondary text, captions and placeholders: slate 600 or darker. Slate 500 just passes on white but drops to 4.54:1 on slate 50, so leave it as the lightest option, not the default.
- Input borders and other control outlines: slate 500 or darker, which passes 3:1 with room to spare.
- Decorative dividers and card borders: slate 200 or 300. They don't carry information, so they can be faint.
- Brand text, links and primary buttons: indigo 600 or 700.
Pair steps across scales
The same logic works for colored surfaces. Pair a light step of one color with a dark step of the same hue, and you get a tinted area that still reads clearly: indigo 700 #4338CA text on an indigo 50 #EEF2FF background is 7.06:1. Pairs from the middle of two scales, such as indigo 500 on slate 300, are where contrast usually goes wrong.
Semantic colors that pass
Success, warning, error and information colors are often picked to look bright and obvious, then fail as text. Choose the darker steps for anything that holds text or icons, and the lightest steps for the soft backgrounds behind messages.
- Success: green #15803D, 5.01:1 on white and 4.79:1 on its light tint #F0FDF4.
- Warning: amber #B45309, 5.02:1 on white and 4.84:1 on its tint #FFFBEB.
- Error: red #B91C1C, 6.47:1 on white and 5.91:1 on its tint #FEF2F2.
- Information: blue #1D4ED8, 6.70:1 on white and 6.15:1 on its tint #EFF6FF.
- #15803D
- #B45309
- #B91C1C
- #1D4ED8
- #F0FDF4
- #FFFBEB
- #FEF2F2
- #EFF6FF
Text and icon colors for success, warning, error and info, with the soft tints used behind their messages.
Warning is the hardest. Bright yellow and amber look like warnings but carry almost no contrast on white, so use a deep amber or brown-orange for warning text and keep the bright yellow for an icon or a stripe next to it.
Read the full guideDesigning for Color Blindness: Palettes, Patterns and TestingInteractive states
- Hover and pressed: move one or two steps darker, such as indigo 600 to 700. Moving darker keeps white text passing.
- Focus rings: use a step that reaches at least 3:1 against the background, such as indigo 600 on white, and make the ring thick enough to see.
- Selected states: combine a color change with a shape change, such as a filled background or a check mark.
- Disabled: lighter and lower in contrast is expected, but keep the control recognizable and add a cue such as a not-allowed cursor or a tooltip that explains why.
Light and dark themes
A dark theme isn't the light theme inverted. Steps that pass on white often fail on a dark surface, so each role needs its own step in each theme. The brand indigo 600 #4F46E5 that works so well on white is only 2.83:1 on slate 900 #0F172A. In dark mode, the brand color moves up the scale to indigo 400 #818CF8, at 5.98:1.
- Page background: slate 900 #0F172A instead of white. Raised surfaces, such as cards, one step lighter at slate 800 #1E293B.
- Body text: slate 200 #E2E8F0, 14.48:1 on slate 900. Softer than pure white and easier on the eyes.
- Secondary text: slate 400 #94A3B8, 6.96:1 on slate 900 and 5.70:1 on slate 800.
- Primary buttons: indigo 400 with dark text. Slate 950 #020617 text on indigo 400 is 6.76:1.
- Control borders: slate 500 #64748B, 3.75:1 on slate 900. Slate 600 is only 2.35:1, so keep it for decorative lines.
Semantic colors move up the scale too. Use lighter steps for dark mode text: green #4ADE80 (10.24:1 on slate 900), amber #FBBF24 (10.69:1), red #F87171 (6.45:1) and blue #60A5FA (7.02:1). Check them on raised surfaces as well, where the margins are a little smaller.
- #0F172A
- #1E293B
- #E2E8F0
- #94A3B8
- #818CF8
- #4ADE80
- #FBBF24
- #F87171
Background, surface, text, secondary text, brand, success, warning and error for a dark theme, each checked against the background.
Write it down as tokens
Name colors by their role, not by their value, and point each role at a scale step for each theme. Components then use the role, and switching themes only changes which step each role points to.
CSS
:root {
--color-bg: #ffffff;
--color-text: #0f172a;
--color-text-muted: #475569;
--color-border-control: #64748b;
--color-primary: #4f46e5;
--color-on-primary: #ffffff;
}
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #0f172a;
--color-text: #e2e8f0;
--color-text-muted: #94a3b8;
--color-border-control: #64748b;
--color-primary: #818cf8;
--color-on-primary: #020617;
}
}Check the whole palette
Once the palette is built, check it in three ways. Run the key pairs through a contrast checker, including links, placeholders, borders and every semantic color, in both themes. Use the Accessibility tab on any palette's details page to see at a glance how each color does against white, light gray, near-black and black. Then try the palette on a real layout, because a pair that passes on its own can still feel weak next to everything else.
Contrast CheckerTest a text and background pair against WCAG AA and AAA.Palette GeneratorPick a color, apply a harmony rule and refine your palette.Checklist
- Every color, including the neutral, has a scale from light to dark.
- Each step is marked as safe for text, safe for borders, or background only, against both light and dark surfaces.
- Semantic colors use dark steps for text in light mode and light steps in dark mode, with an icon and a label as well as color.
- Hover, focus, selected and disabled states are defined and checked.
- The dark theme has its own step for each role, not an inverted copy of the light theme.
- Roles are written down as tokens so components never pick raw values.
