Picking Accessible Color Palettes: Everything You Asked
I've been fielding questions about color accessibility for years — from junior devs who've just discovered WCAG exists, to senior engineers who know the rules but keep hitting edge cases with their brand's signature teal. So instead of writing a dry overview, I'm just going to answer the actual questions people ask me. Real questions, honest answers.
What even is a contrast ratio, and why does 4.5:1 keep showing up everywhere?
Contrast ratio is a number that describes how different two colors are in perceived luminance — meaning how much light they appear to emit or reflect to the human eye. It's calculated as:
(L1 + 0.05) / (L2 + 0.05)
where L1 is the lighter color's relative luminance and L2 is the darker one's. Luminance goes from 0 (pure black) to 1 (pure white), so the maximum possible contrast ratio is 21:1 (black on white).
The 4.5:1 threshold comes from WCAG 2.1 Level AA — the standard most companies aim for because it's legally defensible in many jurisdictions and genuinely usable for people with moderate vision impairments. The thinking behind 4.5 is that it approximates the vision loss of someone with 20/40 vision, which is about the acuity of an average 80-year-old, or someone with low-grade cataracts.
For large text (18pt regular or 14pt bold), the threshold drops to 3:1. That's AA again. If you want AAA compliance, you're looking at 7:1 for normal text and 4.5:1 for large text — which is significantly harder to achieve with colorful branding.
How do I calculate relative luminance myself? I keep seeing formulas but they're confusing.
Fair enough — it's genuinely a two-step process that trips people up.
First, take each RGB channel (0–255), divide by 255 to get a value between 0 and 1, then apply gamma correction:
if channel <= 0.04045:
linear = channel / 12.92
else:
linear = ((channel + 0.055) / 1.055) ^ 2.4
Then luminance is:
L = 0.2126 * R_linear + 0.7152 * G_linear + 0.0722 * B_linear
Those coefficients aren't arbitrary — they reflect how the human eye weights different wavelengths. Green contributes far more to perceived brightness than blue, which is why a pure blue (#0000FF) and a pure green (#00FF00) look nothing alike in brightness even though they're both "saturated primaries."
In practice, just use a tool. But knowing the formula helps you understand why two colors that look similarly bright to you might still fail contrast — your intuition is based on aesthetics, not the specific weighting of luminance channels.
Can HSL help me build accessible palettes more systematically?
Yes, and this is probably my favorite technique for brand color systems.
HSL (Hue, Saturation, Lightness) separates a color into three components you can tune independently. Contrast is largely determined by lightness difference — not hue, not saturation. Which means you can often preserve your brand's hue while cranking lightness up or down until the contrast works.
Here's the workflow I use:
- Convert your brand color to HSL.
- Lock the hue. Sometimes lock the saturation too.
- Move lightness until the contrast ratio with your background meets the threshold.
- Double-check in a tool because HSL lightness isn't the same as perceptual luminance — they diverge, especially in yellows and blues.
That last point matters a lot. HSL's "lightness" is a mathematical average of the max and min RGB channels, not a perceptual measure. A fully saturated yellow at hsl(60, 100%, 50%) has much higher perceived luminance than a fully saturated blue at hsl(240, 100%, 50%), even though both have the same HSL lightness of 50%.
If you want perceptual uniformity baked in, look at OKLCH — it's a newer color space where changing lightness actually moves perceived brightness linearly. The CSS Color Level 4 spec supports it, and modern browsers handle oklch() natively. Worth learning if you're building a design token system from scratch.
My brand color is #FF6B35 (a warm orange). It fails contrast on white AND dark backgrounds. What do I do?
This is the orange problem, and it's one of the most common pain points I see.
Let's check: #FF6B35 has a relative luminance of about 0.24. Against white (luminance 1.0), that's roughly (1.05 / 0.29) = 3.6:1. Against black (luminance 0), it's (0.29 / 0.05) = 5.8:1. So it actually does pass on dark backgrounds — just not on white.
But let's say your layout is white-background and you need orange text or orange interactive elements. Your options:
- Darken the orange. Shift to something like
#C44A00— same warm-orange feeling but high enough contrast on white. Run it through a contrast checker; you're looking for luminance around 0.07 or lower to hit 4.5:1 against white. - Use orange for decoration, not information. Keep the brand orange for borders, icons-without-text, dividers, and backgrounds — but don't use it as the only signal for interactive elements or error states.
- Pair the orange with a very dark background swatch. If your CTA buttons are orange, put them on near-black. You get the vibrancy of the brand color while clearing the contrast requirement.
What you shouldn't do is pick a slightly different orange that looks close to passing and hope nobody checks. Automated accessibility tools are in CI pipelines now. You will get flagged.
Is there a way to programmatically generate a full palette from one brand hex?
Absolutely — and this is where your developer tools earn their keep.
The approach I'd recommend: take your base brand color, convert to OKLCH (or HSL as a fallback), then generate a range of lightness steps while holding hue and saturation roughly constant. Something like 10 steps from lightness 10% to 95% gives you a Tailwind-style scale.
Then for each step, calculate the contrast ratio against both pure white and pure black, and tag each swatch with which background it's safe on. A step at lightness 30% might be fine on white but fail on black; a step at lightness 80% might be fine on black but fail on white.
In JavaScript this looks roughly like:
// pseudo-code — use a library like 'chroma-js' or 'culori'
const base = parseColor('#FF6B35');
const scale = [10, 20, 30, 40, 50, 60, 70, 80, 90].map(l => {
const swatch = setLightness(base, l);
return {
hex: toHex(swatch),
onWhite: contrastRatio(swatch, '#FFFFFF'),
onBlack: contrastRatio(swatch, '#000000'),
};
});
Libraries like chroma.js or culori handle the heavy lifting of color space conversions. culori in particular has excellent OKLCH support and is actively maintained.
Do contrast ratio tools handle all types of color blindness?
This is a really important question, and the honest answer is: not directly, and you need to think about this separately.
WCAG contrast ratios are based on luminance, not hue discrimination. That means two colors can pass the 4.5:1 contrast ratio while still being indistinguishable to someone with red-green color blindness — because they might have very different luminance values but happen to look the same hue to a deuteranope.
Conversely, red and green can appear very different in luminance and pass contrast checks, while being genuinely confusing to color-blind users who rely on hue to tell them apart.
The fix isn't to abandon contrast ratios — they're still necessary. It's to add a second layer: don't rely on color alone to convey information. If your error state is just a red border, add an icon or a text label. If your chart uses colors to distinguish data series, add texture or labels too. This is WCAG 1.4.1 (Use of Color), which is a separate requirement from contrast.
For simulation, tools like the browser devtools' color-blindness emulation (Chrome DevTools → Rendering → Emulate vision deficiencies) let you preview your UI under different conditions without any extra setup.
Our design system uses semi-transparent overlays. How do I check contrast for those?
Composite them first, then check. WCAG contrast calculations assume opaque colors — they don't account for alpha directly.
So if you have text at rgba(0,0,0,0.7) on a white background, composite it: the effective color is roughly #4D4D4D (black at 70% on white). Then run that composited hex through your contrast checker against the background.
Most contrast tools don't do this automatically, which is a common source of errors in accessibility audits. A semi-transparent gray placeholder might look like it passes visually but actually fails when you do the composite math carefully.
If you're using design tokens, I'd strongly suggest storing your effective final colors (post-composition) as separate tokens rather than relying on the alpha math being done correctly downstream. It reduces the surface area for mistakes.
Any quick sanity checks before I ship a new color system?
A few that have saved me embarrassment more than once:
- Check your focus ring color against all possible backgrounds it might appear on — not just your main background.
- Check placeholder text in form inputs. It's almost always too light and fails contrast. Bump it to at least
#767676on white backgrounds. - Check disabled state colors. WCAG 1.4.3 technically exempts disabled UI components, but that doesn't mean making them invisible is a good idea.
- Run Axe or Lighthouse in CI so regressions get caught before review — not after deploy.
- Print your palette on paper in grayscale. If everything becomes the same shade of gray, your system relies too heavily on hue alone.
Color accessibility isn't a checkbox you tick once. It's something you bake into your tooling so the defaults are right and the violations are loud. Once you have a well-structured palette with pre-verified contrast pairings, the day-to-day decisions get dramatically easier.