DEV Community

Cover image for Why Good-Looking Colors Can Still Fail WCAG Contrast
Hasan Sarwer
Hasan Sarwer

Posted on

Why Good-Looking Colors Can Still Fail WCAG Contrast

A color can look beautiful and still be hard to read.

That is one of the most common mistakes in UI design.

A soft gray paragraph may look elegant.

A yellow warning button may look friendly.

A pastel primary color may match the brand perfectly.

But visual beauty does not automatically mean accessibility.

For text, the important question is not only:

```text id="w23iot"
Does this color look good?




The better question is:



```text id="czggrz"
Can people read this text clearly against its background?
Enter fullscreen mode Exit fullscreen mode

That is where contrast matters.

What contrast ratio means

Contrast ratio measures the difference in relative luminance between two colors.

In practical UI work, that usually means:

```text id="ejsa7x"
text color vs background color




For example:



```css id="vqgj7k"
color: #64748b;
background: #ffffff;
Enter fullscreen mode Exit fullscreen mode

or:

```css id="l9rida"
color: #ffffff;
background: #2563eb;




The ratio is written like this:



```text id="c3e19l"
4.5:1
7:1
3:1
Enter fullscreen mode Exit fullscreen mode

A higher number means stronger light-dark contrast.

A lower number means weaker contrast.

WCAG 2.2 Success Criterion 1.4.3 requires text and images of text to have at least 4.5:1 contrast, with a lower 3:1 threshold for large-scale text.

The important thresholds

For WCAG AA text contrast:

Text type Minimum contrast
Normal text 4.5:1
Large text 3:1

Large text is treated differently because bigger and heavier letters are easier to read at lower contrast. WCAG’s explanation says 18pt text, or 14pt bold text, is considered large enough for the lower 3:1 threshold; it also notes that 14pt and 18pt are approximately 18.5px and 24px in CSS pixels.

So this may pass for a heading:

```css id="6qu0u1"
.heading {
font-size: 2rem;
font-weight: 700;
color: #64748b;
}




But the same color may fail for small body text:



```css id="n2zh2l"
.description {
  font-size: 0.875rem;
  color: #64748b;
}
Enter fullscreen mode Exit fullscreen mode

The color did not change.

The usage changed.

That is why contrast should be checked in context.

Good-looking does not mean readable

Designers and developers often judge colors visually.

That is understandable.

We look at a button and think:

```text id="vle6x6"
This looks clean.




or:



```text id="761o9m"
This looks modern.
Enter fullscreen mode Exit fullscreen mode

But contrast is not about whether a color combination feels modern.

It is about whether the foreground and background are different enough in luminance.

Two colors can have different hues but still be close in brightness.

For example:

```css id="i6wl3e"
color: #facc15;
background: #ffffff;




Yellow on white may look cheerful, but it can be difficult to read.

The problem is not the hue.

The problem is low luminance contrast.

WCAG explains that hue and saturation are not the key factor in its contrast calculation; the goal is sufficient light-dark contrast so text remains readable, including for people with low vision or color vision deficiency.

## Button text is a common failure

A common button pattern is:



```css id="s6hcqa"
.button {
  background: var(--color-primary);
  color: white;
}
Enter fullscreen mode Exit fullscreen mode

This works while the primary color is dark enough.

For example:

```css id="xf3crr"
--color-primary: #2563eb;




White text on a darker blue can be readable.

But if the brand color changes to yellow:



```css id="9o3pgd"
--color-primary: #facc15;
Enter fullscreen mode Exit fullscreen mode

the component still says:

```css id="wa6xo5"
color: white;




Now the button may look bright and attractive, but the text can become hard to read.

The component assumed that every primary button should use white text.

That assumption is unsafe.

A better token structure is:



```css id="pk2x49"
:root {
  --color-primary: #facc15;
  --color-on-primary: #111827;
}
Enter fullscreen mode Exit fullscreen mode

Then:

```css id="m3axjp"
.button {
background: var(--color-primary);
color: var(--color-on-primary);
}




Now the theme controls both sides of the contrast relationship.

The background token and foreground token are paired.

That is why `--color-on-primary` matters.

## Every filled color needs a foreground partner

For important filled UI states, do not define only the background color.

Define the foreground that belongs on it.



```css id="cmlj1c"
:root {
  --color-primary: #2563eb;
  --color-on-primary: #ffffff;

  --color-danger: #dc2626;
  --color-on-danger: #ffffff;

  --color-success: #15803d;
  --color-on-success: #ffffff;

  --color-warning: #facc15;
  --color-on-warning: #111827;
}
Enter fullscreen mode Exit fullscreen mode

Then components stay safe:

```css id="fkjb8k"
.button-primary {
background: var(--color-primary);
color: var(--color-on-primary);
}

.button-danger {
background: var(--color-danger);
color: var(--color-on-danger);
}

.button-warning {
background: var(--color-warning);
color: var(--color-on-warning);
}




The rule is simple:



```text id="u3gov4"
Do not theme the background alone.
Theme the foreground that belongs on it.
Enter fullscreen mode Exit fullscreen mode

Muted text is another common problem

Muted text is useful.

It helps create hierarchy.

But muted text often becomes too faint.

For example:

```css id="pg5kqi"
.card-description {
color: #94a3b8;
background: #ffffff;
}




This may look soft and elegant.

But if the text is small, long, or important, it may be difficult to read.

Muted does not mean barely visible.

A better approach is to treat muted text as a real semantic token:



```css id="4q3b98"
:root {
  --color-text: #0f172a;
  --color-text-muted: #475569;
}
Enter fullscreen mode Exit fullscreen mode

Then use it intentionally:

```css id="xfs6jw"
.card-title {
color: var(--color-text);
}

.card-description {
color: var(--color-text-muted);
}




The muted token should still pass the contrast level required for its use.

If it is body-sized text, aim for at least 4.5:1.

If it is large text, the threshold may be 3:1.

The mistake is assuming that secondary text is allowed to be unreadable.

It is not.

## Placeholder text also matters

Placeholder text often uses very light gray:



```css id="7s1wir"
input::placeholder {
  color: #cbd5e1;
}
Enter fullscreen mode Exit fullscreen mode

This may look subtle, but it can be difficult to read.

If the placeholder contains helpful information, users need to be able to read it.

WCAG’s explanation notes that the text contrast requirement applies to text in the page, including placeholder text and text shown on hover or keyboard focus.

So placeholder color should not be chosen only for style.

It should be checked against the input background.

```css id="1ztl32"
:root {
--color-placeholder: #64748b;
}

.input::placeholder {
color: var(--color-placeholder);
}




## Warning colors are tricky

Warning colors often use yellow or amber.

They look friendly and attention-grabbing.

But yellow is usually light.

That means yellow backgrounds often need dark text.

This may fail:



```css id="159vsm"
.alert-warning {
  background: #facc15;
  color: #ffffff;
}
Enter fullscreen mode Exit fullscreen mode

This is safer:

```css id="9omw10"
.alert-warning {
background: #facc15;
color: #111827;
}




The same applies to warning buttons:



```css id="iwq783"
.button-warning {
  background: var(--color-warning);
  color: var(--color-on-warning);
}
Enter fullscreen mode Exit fullscreen mode

Instead of:

```css id="y766d7"
.button-warning {
background: var(--color-warning);
color: white;
}




The warning color may look good either way.

But only one version may be readable.

## Danger colors can also fail

Developers often assume red always has enough contrast.

That is not always true.

Light red text on a light red background can fail:



```css id="gm3q6w"
.alert-danger {
  background: #fee2e2;
  color: #f87171;
}
Enter fullscreen mode Exit fullscreen mode

It may look visually consistent, but the foreground and background are too close.

A safer structure separates:

```css id="r7pw8k"
:root {
--color-danger: #dc2626;
--color-danger-surface: #fef2f2;
--color-danger-border: #fca5a5;
}




Then:



```css id="u9wt0u"
.alert-danger {
  background: var(--color-danger-surface);
  color: var(--color-danger);
  border: 1px solid var(--color-danger-border);
}
Enter fullscreen mode Exit fullscreen mode

But even this should be checked.

Do not assume semantic names automatically guarantee accessibility.

Semantic tokens make the system understandable.

Contrast checks make the system safer.

Disabled text is a special case

Disabled controls are visually different from active controls.

They are often low contrast.

WCAG’s explanation says inactive user interface components are not required to meet the same contrast requirement.

But that does not mean disabled UI should become invisible.

Users still need to understand what is unavailable.

For disabled states, use dedicated tokens:

```css id="6xcj05"
:root {
--color-disabled-background: #e2e8f0;
--color-disabled-text: #64748b;
--color-disabled-border: #cbd5e1;
}




Then:



```css id="lupfsf"
.button:disabled,
.input:disabled {
  background: var(--color-disabled-background);
  color: var(--color-disabled-text);
  border-color: var(--color-disabled-border);
  cursor: not-allowed;
}
Enter fullscreen mode Exit fullscreen mode

The goal is:

```text id="qg3ffx"
clearly unavailable




not:



```text id="912yeu"
barely visible
Enter fullscreen mode Exit fullscreen mode

Do not check colors alone

A single color does not pass or fail contrast by itself.

Contrast is always a pair.

This is incomplete:

```text id="8s951f"
Is #64748b accessible?




The real question is:



```text id="i83dqk"
Is #64748b accessible on #ffffff for this text size and weight?
Enter fullscreen mode Exit fullscreen mode

The same color may pass on one background and fail on another.

```css id="trt1e0"
color: #64748b;
background: #ffffff;




is different from:



```css id="7x3u07"
color: #64748b;
background: #0f172a;
Enter fullscreen mode Exit fullscreen mode

So token systems should check relationships:

```text id="47vved"
text vs background
muted text vs surface
primary text vs primary background
danger text vs danger surface
warning text vs warning surface
disabled text vs disabled background




Not just individual color values.

## Contrast should be part of the token system

A useful theme system does not only store colors.

It stores relationships.

For example:



```css id="mc7i3x"
:root {
  --color-background: #ffffff;
  --color-surface: #f8fafc;

  --color-text: #0f172a;
  --color-text-muted: #475569;

  --color-primary: #2563eb;
  --color-on-primary: #ffffff;

  --color-warning: #facc15;
  --color-on-warning: #111827;
}
Enter fullscreen mode Exit fullscreen mode

The important pairs are:

```text id="1m05cu"
--color-text on --color-background
--color-text-muted on --color-surface
--color-on-primary on --color-primary
--color-on-warning on --color-warning




This is why foreground tokens like `onPrimary`, `onDanger`, and `onWarning` are not optional decoration.

They are part of the contrast contract.

## Beauty and accessibility should work together

The lesson is not:



```text id="crbf5n"
Do not use beautiful colors.
Enter fullscreen mode Exit fullscreen mode

The lesson is:

```text id="56ode4"
Do not assume beautiful colors are readable.




A good color system should support both:

* visual identity
* readable UI
* dark mode
* state colors
* semantic meaning
* contrast checks

When a color fails, the fix is not always to destroy the design.

Sometimes a small adjustment to lightness is enough.

Sometimes the foreground token needs to change.

Sometimes the background surface needs a darker or lighter variant.

The key is to check the actual foreground/background pair.

## Practical checklist

Before shipping a color system, check these pairs:



```text id="2pcu1p"
Normal text on page background
Normal text on surface
Muted text on page background
Muted text on surface
Placeholder text on input background
Primary button text on primary background
Secondary button text on secondary background
Danger text on danger surface
Success text on success surface
Warning text on warning surface
Disabled text on disabled background
Focus ring against nearby background
Enter fullscreen mode Exit fullscreen mode

Then ask:

```text id="0izh7c"
Does this pair pass for the text size where it is used?




Not:



```text id="hlc7wn"
Does this color look good in isolation?
Enter fullscreen mode Exit fullscreen mode

The bottom line

Good-looking colors can still fail WCAG contrast.

The issue is not always the palette.

Often the issue is the relationship between foreground and background.

A strong token system should include:

```css id="mw007y"
--color-primary
--color-on-primary

--color-danger
--color-on-danger

--color-warning
--color-on-warning

--color-text
--color-text-muted
--color-background
--color-surface




Then those pairs should be checked.

Because UI accessibility is not only about choosing beautiful colors.

It is about choosing colors that remain readable in the places where they are actually used.
Enter fullscreen mode Exit fullscreen mode

Top comments (0)