Most design systems define button background colors.
But many of them forget the other half of the button:
the text color that must be readable on top of that background.
That is where contrast problems often begin.
A primary button may look fine today:
.button-primary {
background: var(--color-primary);
color: white;
}
This works while --color-primary is a dark blue, dark purple, or another deep brand color.
But what happens when the primary color changes?
:root {
--color-primary: #facc15;
}
Now the button background is yellow.
But the text is still white.
The background changed.
The foreground did not.
That is the contrast problem many developers miss.
Background tokens are not enough
A background color alone does not tell the full accessibility story.
This token is incomplete:
--color-primary: #2563eb;
It tells us what the button background should be.
But it does not tell us what text color should appear on top of it.
So developers guess.
Usually they write:
color: white;
That creates a hidden assumption:
Primary backgrounds will always be dark enough for white text.
But design systems change.
Brands change.
Themes change.
Dark mode changes.
Warning, success, danger, and secondary colors may all behave differently.
That is why every filled background token should have a readable foreground partner.
Use foreground pairs
A safer structure looks like this:
:root {
--color-primary: #2563eb;
--color-on-primary: #ffffff;
--color-danger: #dc2626;
--color-on-danger: #ffffff;
--color-warning: #facc15;
--color-on-warning: #111827;
--color-success: #16a34a;
--color-on-success: #ffffff;
}
Now each background role has a text role that belongs to it.
The component does not decide the contrast pair.
The theme does.
.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);
}
This is a small naming change, but it changes the responsibility of the system.
Instead of every component guessing text color, the design token system defines the readable relationship.
The warning button example
Warning buttons are a common place where this problem appears.
Many developers write:
.button-warning {
background: #facc15;
color: white;
}
Visually, this may look clean.
But yellow is usually a light color.
White is also light.
So the text can become hard to read.
A better warning pair is:
:root {
--color-warning: #facc15;
--color-on-warning: #111827;
}
Then:
.button-warning {
background: var(--color-warning);
color: var(--color-on-warning);
}
The background still communicates warning.
But the text remains readable.
This also helps with dark mode
Foreground pairs become even more useful when themes change.
:root {
--color-primary: #2563eb;
--color-on-primary: #ffffff;
}
:root[data-theme="dark"] {
--color-primary: #60a5fa;
--color-on-primary: #0f172a;
}
The button component does not change:
.button-primary {
background: var(--color-primary);
color: var(--color-on-primary);
}
Only the token values change.
That is the benefit of semantic tokens.
Components keep a stable contract.
Themes control the readable pair.
Good button tokens describe relationships
A stronger button token system should include pairs like:
--color-primary
--color-on-primary
--color-secondary
--color-on-secondary
--color-danger
--color-on-danger
--color-success
--color-on-success
--color-warning
--color-on-warning
The on-* token means:
This is the color used on top of that background.
That simple idea prevents a lot of contrast mistakes.
It also makes the design system easier for developers to use.
They no longer need to ask:
“Should this button text be white or dark?”
The token already answers it.
The real problem
The problem is not that developers choose bad colors.
The problem is that many systems only define half of the relationship.
They define the background.
But they leave the foreground to the component.
That creates inconsistent buttons, fragile themes, and accessibility problems that appear later.
A better rule is:
Every meaningful background token should have a readable foreground token.
Especially for filled UI states like:
primary
secondary
danger
success
warning
info
disabled
selected
inverse
Final thought
Button accessibility is not only about choosing a nice background color.
It is about defining the text color that belongs on that background.
So instead of this:
background: var(--color-primary);
color: white;
Prefer this:
background: var(--color-primary);
color: var(--color-on-primary);
The first version assumes contrast.
The second version designs for it.
That is the button contrast problem most design systems ignore.
Top comments (0)