Color Accessibility

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 Guide

Start 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.

Open in generatorPalette detailsVisualize

  • #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.

Open in generatorPalette detailsVisualize

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.
Shades & TintsTurn one color into a full scale of lighter and darker versions.

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.

Open in generatorPalette detailsVisualize

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 Testing

Interactive 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.

Open in generatorPalette detailsVisualize

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;
  }
}
Role-based color variables with a light and a dark value. Every pair here passes the level its role needs.

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

  1. Every color, including the neutral, has a scale from light to dark.
  2. Each step is marked as safe for text, safe for borders, or background only, against both light and dark surfaces.
  3. 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.
  4. Hover, focus, selected and disabled states are defined and checked.
  5. The dark theme has its own step for each role, not an inverted copy of the light theme.
  6. Roles are written down as tokens so components never pick raw values.
Palette VisualizerSee your palette on real designs, outfits and rooms.

Put it into practice

Unsaved changes

“” has changes that aren't saved to your wishlist. Save them before continuing?