A client changes their brand color to a pale light blue via the admin panel. Instantly, white text in the header becomes almost unreadable, and a subsequent accessibility audit returns dozens of contrast violations at once. When developing themeable sites or platforms with customizable brand colors for clients, this scenario is all too common. Altering a single color causes the readability of text, icons, and borders placed over it to collapse simultaneously.
The root cause lies in architectural designs where the foreground color (text color) is maintained as a fixed value independent of the background color. Hardcoding values like --bg: #1a73e8; --text: #ffffff; keeps text white even when the background shifts to a pale tint, causing contrast ratios to fall below WCAG thresholds. On sites where backgrounds change via themes or user settings, contrast must be continuously checked by hand unless teams shift to deriving foreground colors automatically from the background.
This is where CSS's contrast-color() function comes into play. Passing a background color causes it to return whichever option (black or white) offers higher contrast against it. In this article, we outline implementation steps for using this function as the backbone of a self-correcting color system that automatically maintains readability across color changes in client projects, while addressing pitfalls the function alone cannot solve through thoughtful architectural design.
Abandoning fixed foregrounds: deriving foreground from background
First, understand the baseline behavior of contrast-color(). When passed a single color as an argument, it returns whichever of black or white has higher contrast against that background. If contrast is equal, it returns white. In short, it lets the browser determine whether black or white text is more legible over a given background.
.button {
background: var(--brand);
/* ブランドカラーに応じて白か黒かを自動で選ぶ */
color: contrast-color(var(--brand));
}
Historically, this evaluation was handled by calculating luminance and swapping classes via Sass custom functions, build scripts, or JavaScript theme runtimes. On sites where --brand changed dynamically from outside sources, JavaScript was mandatory, often causing flash-of-unreadable-color issues before initial render. contrast-color() encapsulates foreground resolution directly within the cascade, allowing the foreground to adjust simply by overriding a background custom property. The fundamental shift is that theme switching transforms from “full class replacement plus recalculation” into “a single-line property override.”
The architectural philosophy of shifting toward native CSS is covered in CSS Custom Functions with @function regarding moving token calculations from build steps into the cascade. Viewing contrast-color() as the component responsible for evaluating color readability within that broader movement clarifies its role.
Operating on the premise that a black-or-white choice fails on midtones
Here is the most crucial caveat for custom client projects: contrast-color() in CSS Color Level 5 can only return a binary choice between black and white. It does not select an accessible color from an arbitrary custom palette (an expanded version that selects from multiple candidates is under discussion in Color Level 6 and is not viable for production today).
Furthermore, intermediate background tones exist where neither black nor white can satisfy WCAG AA criteria (4.5:1 for normal text). For instance, over a medium-lightness blue like #2277d3, contrast-color() returns black, which remains difficult to read at smaller font sizes. MDN explicitly notes that it is recommended for use against very light or dark colors; the function does not magically produce readable colors over midtone backgrounds.
| Background luminance band | contrast-color() behavior | Handling in client projects |
|---|---|---|
| Light colors (pale backgrounds) | Returns black; easily meets criteria | Safe to rely on directly |
| Dark colors (deep backgrounds) | Returns white; easily meets criteria | Safe to rely on directly |
| Midtones | Returns black or white, but may fall short of criteria | Do not rely on the function; avoid via palette design |
In other words, a self-correcting color system is not finished merely by applying contrast-color(). While automating foreground colors, you must simultaneously design the background palette to lean clearly light or dark, avoiding risky midtones from the start. If this separation of responsibilities is omitted, contrast deficiencies will persist despite adopting the function, creating a false sense of security that automation has solved the problem. Color automation and midtone-avoiding palette design must go hand in hand.
Integrating into token design: generating foregrounds from background roots
The core of this design is aligning token dependency directions. Make the background color token the single source of truth, deriving foreground, border, and icon colors from it. The key is to avoid managing foreground colors manually as independent tokens.
@property --surface {
syntax: '<color>';
inherits: true;
initial-value: #1a73e8;
}
:root {
/* 背景=真実の源。前景はここから導出する */
--surface: #1a73e8;
--on-surface: contrast-color(var(--surface));
}
/* テーマやブランド設定では背景トークンだけを上書きする */
[data-theme='brandlight'] {
--surface: #eaf3ff; /* 前景は触らない。自動で黒に切り替わる */
}
.panel {
background: var(--surface);
color: var(--on-surface);
}
Declaring --surface as a <color> type using @property ensures that invalid values simply cause the declaration to be ignored and fall back to initial values without cascading into broader styling breakages. As a rule, themes and user settings should only modify --surface tokens, leaving --on-surface tokens to derivation. Maintaining this one-way dependency guarantees that the self-correcting property—where updating a background automatically adjusts the text color—is built directly into the token infrastructure.
Avoiding midtones takes effect during this palette definition phase. Rather than allowing freeform color input for brand colors in admin panels, mapping selections to predefined light and dark surface tokens constrains brand colors to luminance bands where contrast-color() operates safely. This design decision trades a small degree of color freedom for structurally guaranteed legibility. Maintaining this structure across design systems connects directly with centralized token management discussed in AI-Ready Design Systems.
Writing fallbacks first: keeping text visible in unsupported environments
In April 2026, contrast-color() became supported across all three major engines (Chrome 147, Firefox 146, and Safari 26.0), entering Baseline as Newly available. However, “Newly available” simply means it runs in modern browsers; legacy browsers lingering in enterprise systems and B2B portals may lack support. In custom development, omitting fallbacks because a feature works in the latest browsers leads directly to incidents where foreground colors fail to resolve and text disappears into backgrounds.
The standard fallback pattern uses a two-tier structure: write a static foreground color first, then override it with @supports only in supported browsers. Leveraging CSS's cascade rules, unsupported browsers receive a safe fixed value, while supported browsers receive the dynamic automated value.
.panel {
background: var(--surface);
/* フォールバック: 未対応ブラウザはこの固定値を使う */
color: #ffffff;
}
/* 対応ブラウザだけ自動値で上書きする */
@supports (color: contrast-color(black)) {
.panel {
color: contrast-color(var(--surface));
}
}
Crucially, the fallback fixed value must also remain legible. If this value is hardcoded to white while allowing pale background themes, unsupported browsers will reproduce the exact unreadable white text issue highlighted at the start. Fallback values should lean toward the safest side across the range of backgrounds a token might take. Designing the palette to bias backgrounds toward clearly light or dark tones simplifies choosing fallback values. Always verify before handover that the system design and fallbacks share the same baseline assumptions.
Adopting native CSS features using conditional branching or feature queries with @supports follows the same framework outlined in CSS Anchor Positioning. When leveraging modern CSS features in client projects, deciding how to gracefully degrade in unsupported environments before writing the feature itself is standard practice.
Client project implementation examples and handover agreements
In a SaaS appointment management platform we supported (serving medical clinics, client name withheld), an admin feature allowed each clinic to set its brand color, which reflected directly across headers and button backgrounds. A notable number of clinics chose pale pastels, rendering white text and icons unreadable, which generated ongoing user inquiries and accessibility audit violations. The cause was a fixed white foreground paired with tenant-controlled backgrounds.
As a solution, we introduced an intermediate layer that evaluated the luminance of tenant-entered brand colors, mapping them to predefined light or dark surface tokens while deriving foreground, icon, and border colors via contrast-color(). For unsupported browsers, static foreground values matching the assigned surface were placed outside @supports. Consequently, whichever color a tenant selected, the foreground adapted automatically, drastically reducing contrast inquiries and resolving the audit findings. The key was not merely applying the function, but establishing an intermediate layer that channeled freeform input into tokens within safe luminance bands.
Items to align on with clients during handover generally center on the following three points.
| Decision Item | Details | Evaluation criteria |
|---|---|---|
| Target scope | Types of foregrounds to automate (text, icons, borders) | How much to delegate to the function versus maintain manually |
| Handling midtones | Whether to include an intermediate layer mapping freeform colors to safe luminance bands | Prioritizing brand color flexibility versus readability |
| Fallbacks | Foreground values in unsupported browsers and scope of browser support guarantees | Tolerance for visual presentation variances in legacy environments |
Leaving these three points ambiguous while simply stating “we will automate contrast via contrast-color()” causes midtone breakages or missing text in legacy browsers to surface later, leading to disputes over out-of-scope work. In client development, presenting the benefits of automation alongside the architectural costs required to compensate for its limitations from the outset is essential to professional integrity.
Where to begin
Websites with legibility issues typically have problems stemming from one root cause: foreground colors configured as fixed values independent of backgrounds. As a first step, test switching between several themes or brand colors on your live site to identify elements where foreground colors remain static rather than adapting to backgrounds. Those are your non-self-correcting points, immediately highlighting priorities for adopting contrast-color() and midtone-avoiding palette structures.
From there, if your platform supports freeform color inputs, consider whether you can incorporate an intermediate layer into your architecture that channels user inputs into tokens within safe luminance bands. If your site features theming or brand color customization and faces contrast-related audit findings or user complaints, please reach out to GleamHub. We will review your existing token architecture and theme-switching setup to help you transition to a self-correcting color system with robust fallback strategies.
Sources
- Algorithmic Theming Engines: Building Self-Correcting Color Systems With contrast-color() — Smashing Magazine
- contrast-color() CSS function — MDN
- contrast-color() — CSS-Tricks Almanac
- Automated accessible text with contrast-color() — una.im
- [css-color-6] Rename color-contrast()? — w3c/csswg-drafts Issue #7557









