Design an Accessible Color System: Contrast, States, Dark Mode and CSS Tokens
Choosing three attractive brand colors is easy. Turning them into a dependable interface is harder. A design needs readable text, unmistakable buttons, visible keyboard focus, meaningful status messages, and a dark theme that does not collapse into low-contrast gray. These requirements can be handled systematically rather than one hex code at a time.
This guide starts with a small palette, turns it into semantic CSS tokens, and tests the combinations that visitors actually see. You can explore candidate colors and their WCAG contrast ratios with Web2Generators Color Studio. Its introduction to Color Studio explains the palette, conversion, shade, and export features.
Separate palette colors from interface roles
A palette says which colors belong together. A color system says what each color does. The difference matters when you redesign a page or add a dark theme.
Start with roles rather than names such as --blue-500 in component rules:
--surfaceand--surface-raisedfor page and card backgrounds.--textand--text-mutedfor primary and secondary copy.--linkfor navigational text.--action-bgand--action-textfor primary buttons.--borderand--focusfor component boundaries and keyboard navigation.--errorand--successfor messages, accompanied by visible text or another cue that communicates the meaning without color.
An accent color can be visually striking while still being unsuitable for small white text. Do not assume the brand swatch is automatically the button background, link color, focus outline, and chart color.
Check contrast where colors meet
WCAG 2.2 Level AA generally requires a minimum 4.5:1 contrast ratio for ordinary text and 3:1 for large text. Large text means at least 18 pt (24 CSS px), or 14 pt bold (about 18.67 CSS px); a heading does not qualify just because it feels prominent. Certain essential user interface boundaries and meaningful graphics also need 3:1 against adjacent colors under the non-text contrast criterion.
For a practical review, build a short matrix:
| Interface element | Test this pair | Useful starting target |
|---|---|---|
| Paragraph | Text on page surface | At least 4.5:1 |
| Muted helper text | Muted text on card | At least 4.5:1 when it conveys information |
| Primary button label | Label on button fill | At least 4.5:1 for ordinary text |
| Large heading | Heading on surface | At least 3:1 if it qualifies as large text |
| Important input boundary | Border against surrounding surface | 3:1 where the boundary conveys the control |
| Focus indicator | Indicator against relevant adjacent colors | 3:1 against adjacent colors for an author-styled indicator |
These are checkpoints, not a complete accessibility audit. Check actual rendered states, including hover, focus, selected, disabled, validation error, and high contrast mode. Inactive controls are exempt from these text and non-text contrast criteria, but their labels should still be understandable. A dark button might pass on white but disappear against a dark panel.
Use Color Studio’s contrast checker to evaluate pairs, then verify the final page with browser accessibility tools and, ideally, keyboard and assistive-technology testing. The authoritative requirements are in WCAG 2.2.
Create semantic CSS tokens
A compact starting point might look like this. These sample values are illustrative, not a set of independently certified contrast pairs. Validate the combinations you actually deploy.
:root {
color-scheme: light;
--surface: #ffffff;
--surface-raised: #f5f7fb;
--text: #172033;
--text-muted: #46536a;
--border: #6a7689;
--action-bg: #174a8b;
--action-text: #ffffff;
--link: #164a86;
--focus: #c24b14;
}
a {
color: var(--link);
text-decoration: underline;
}
body {
background: var(--surface);
color: var(--text);
}
.card {
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: 0.75rem;
padding: 1.25rem;
}
.button-primary {
background: var(--action-bg);
color: var(--action-text);
border: 2px solid transparent;
border-radius: 0.4rem;
padding: 0.7rem 1rem;
}
:where(a, button, input, select, textarea):focus-visible {
outline: 3px solid var(--focus);
outline-offset: 3px;
}
Use .button-primary on a native <button> for an action, or on an <a> with an href for navigation. Keep inline text links underlined so color is not their only identifying cue. The example covers base colors and focus; add hover, selected and validation states for the components you actually use.
The advantage is maintenance. If a client needs a different brand color, you update tokens and retest role pairs instead of searching hundreds of declarations. If a component needs a contrasting focus ring on two surfaces, create a second focus token rather than forcing one color everywhere.
For realistic cards and forms, populate your mockup using the Lorem Ipsum generator. Its different lengths and HTML list output expose wrapping and spacing problems that a single short headline might hide.
Add a dark theme without merely inverting colors
A dark theme is a separate set of semantic relationships. Reversing lightness mechanically may turn a calm light theme into a glaring dark one, particularly for saturated accents and subtle borders.
@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
--surface: #111723;
--surface-raised: #1c2635;
--text: #f3f6fc;
--text-muted: #c6d0de;
--border: #93a0b3;
--action-bg: #b9d6ff;
--action-text: #0e223c;
--link: #b5d5ff;
--focus: #ffd071;
}
}
Test screenshots and live controls in both modes. Native form widgets and scrollbars can respond to color-scheme, but that does not replace checking your own custom controls. If you offer a manual theme switcher, make sure it can override the OS preference and remember the visitor’s choice without preventing access to the page.
Color Studio can convert between HEX, RGB, HSL, Oklab, and OKLCH, making it easier to experiment with lightness and chroma. Remember that an OKLCH color can be outside an output display’s sRGB gamut; inspect its rendered result rather than assuming the numerical conversion preserves every vivid tone.
Do not use color as the only message
Imagine a registration field with a red border but no error text. A visitor who cannot distinguish the border color may have no idea why submission failed. A text message such as “Enter a valid email address” and a programmatic association with the field are more robust. The same principle applies to success badges, chart series, and table statuses.
Try error and success states with neutral or monochrome viewing conditions. Ask: if all colors disappeared, would the user still know what happened? If not, add words, shapes, labels, or icons that communicate the meaning independently.
For icons beside visible text, hide the decorative symbol from assistive technology to avoid duplicate announcements. Give icon-only controls a meaningful accessible name, and prefer visible text for important actions. Our guide to modern CSS sprites covers the distinction between decorative images and meaningful controls. For a working example of success and failure feedback that goes beyond a color change, see Build a Copy-to-Clipboard Button That Works for Everyone.
Test focus and Windows high contrast modes
Keyboard navigation needs a visible indication of focus. Test Tab and Shift+Tab from the page header through dialogs and forms. A :focus-visible ring should remain recognizable on links, filled buttons and focusable cards in both themes. Check that sticky headers and open overlays do not hide the focused control. Avoid replacing the browser’s outline with a barely visible shadow.
Windows high contrast settings may cause browsers to replace many author-selected colors. Important boundaries that rely only on background colors or shadows can then disappear. Test in a forced-colors environment; for special cases, system color keywords such as Canvas, CanvasText, or Highlight are useful. Usually it is better to let the browser’s forced-color behavior work than to opt out with forced-color-adjust: none.
Also test gradients, semi-transparent overlays, and images behind text. A contrast checker evaluating two flat swatches cannot guarantee readable text over every pixel of a photograph. Prefer a solid or sufficiently opaque text panel when the background varies.
Review a real page before release
Before releasing a new theme, inspect a small real page rather than a palette sheet alone. Include a heading, two paragraphs, a link, a form label and error, an active and inactive button, a card border, and a focused element. Repeat the review in light mode, dark mode, at 200% zoom, and in high contrast mode if available. Then check keyboard navigation and whether screen-reader announcements still make sense.
A good color system is not a collection of perfect hex codes. It is a small, documented set of roles that remains usable as content, devices, and user preferences change.
Want to try it with your own brand color? Start in Color Studio, check a few real text and background pairs, and export the palette as CSS variables, then map those values to the semantic roles in your stylesheet.