DEV Community

Cover image for A Practical Workflow for Building Accessible Color Systems in CSS
James Miller
James Miller

Posted on

A Practical Workflow for Building Accessible Color Systems in CSS

A dependable color system is more than a set of attractive swatches. It is UI infrastructure: the system has to communicate hierarchy, preserve readability, support interactive states, and remain understandable across themes and different types of color vision.

This is the workflow I use to move from an early palette idea to production-ready CSS tokens.

1. Start with semantic roles, not color names

Naming a token blue-500 tells you what it looks like, but not what it does. A semantic name such as action-primary or text-muted expresses intent and makes future redesigns much easier.

A small interface usually needs these roles:

  • Canvas and elevated surfaces
  • Primary, secondary, and muted text
  • Primary and secondary actions
  • Borders and focus indicators
  • Success, warning, error, and informational feedback

Keep the first version intentionally small. It is easier to add a role after observing a real need than to maintain dozens of almost-identical tokens.

2. Generate candidates, then edit with constraints

A harmony rule can give you a useful starting point, but generated colors are not automatically usable UI colors. Saturation, lightness, and contrast still need deliberate adjustment.

For every candidate, ask three questions:

  1. Does it have a clear job in the interface?
  2. Is it distinguishable from neighboring roles?
  3. Can it meet contrast requirements in the context where it will be used?

If you want to prototype and compare the system before turning it into tokens, this free color palette generator lets you lock useful swatches, try harmony modes, check contrast, simulate common color-vision differences, and export the result. I built it to keep those checks in one workflow instead of moving between several disconnected tools.

3. Test combinations, not isolated swatches

Accessibility belongs to a color pair. A hex value cannot be called accessible by itself because its result depends on the foreground or background used with it.

Create a simple contrast matrix for the combinations your product actually renders:

  • Body text on the main canvas
  • Muted text on elevated surfaces
  • Button labels on default and hover backgrounds
  • Error text on error-tinted surfaces
  • Focus rings beside nearby borders

As a practical baseline, normal text should reach a 4.5:1 contrast ratio, while large text should reach 3:1. Also remember that contrast is not the whole experience: avoid using color as the only signal for errors, status, or selection.

4. Separate primitives from semantic tokens

Primitives are the available color values. Semantic tokens map those values to jobs. Keeping these layers separate makes a theme easier to maintain.

:root {
  /* Primitives */
  --indigo-50: #eef2ff;
  --indigo-600: #4f46e5;
  --indigo-700: #4338ca;
  --slate-50: #f8fafc;
  --slate-700: #334155;
  --slate-950: #020617;

  /* Semantic roles */
  --color-canvas: var(--slate-50);
  --color-surface: #ffffff;
  --color-text: var(--slate-950);
  --color-text-muted: var(--slate-700);
  --color-action: var(--indigo-600);
  --color-action-hover: var(--indigo-700);
  --color-focus: var(--indigo-600);
}
Enter fullscreen mode Exit fullscreen mode

Components should consume the semantic layer. If the brand color changes later, component code does not need a search-and-replace operation.

5. Design every interactive state

A button is not one color. It has default, hover, active, focus-visible, disabled, and sometimes loading states. Links and form controls have a similar state model.

Do not create a hover state by making the color slightly different and hoping users notice. Check that it remains readable and pair it with another cue when appropriate. Underlines, border changes, icons, and motion can reinforce meaning without relying only on hue.

For keyboard navigation, a focus indicator should be obvious against both the control and its surroundings. A two-layer ring is often more resilient than a single color when controls appear on varied surfaces.

6. Treat dark mode as a mapping, not an inversion

Dark mode needs its own semantic mapping. Simply inverting values can produce glowing text, weak borders, and saturated accents that feel harsher on a dark canvas.

@media (prefers-color-scheme: dark) {
  :root {
    --color-canvas: #020617;
    --color-surface: #0f172a;
    --color-text: #f8fafc;
    --color-text-muted: #cbd5e1;
    --color-action: #818cf8;
    --color-action-hover: #a5b4fc;
    --color-focus: #c7d2fe;
  }
}
Enter fullscreen mode Exit fullscreen mode

Recheck the contrast matrix after remapping. A pair that works in the light theme says nothing about the equivalent dark-theme pair.

7. Validate in real components

Swatch pages hide problems. Put the tokens into actual buttons, form fields, cards, banners, tables, and empty states. Then test zoom, keyboard focus, disabled controls, validation messages, and content with realistic length.

I maintain Free Color Tool while applying this workflow, and the most useful improvements have come from testing colors inside real interface patterns rather than judging a palette in isolation.

Finally, test with more than one signal: automated contrast checks, color-vision simulation, grayscale inspection, and feedback from people using the interface. A good system makes important information readable without forcing color to carry the entire message.

Takeaway

The strongest color systems connect creative exploration to explicit rules. Define roles first, test the pairs that appear in the product, map primitives to semantic tokens, and verify every state in context. That process produces CSS that is easier to change and an interface that is easier to understand.

Top comments (0)