How to Build an Accessible Color System
On this page
- Key takeaways
- Why color scales from 50 to 950
- Why even lightness matters
- Choosing accessible text and background pairings
- WCAG versus APCA in plain terms
- WCAG 2 contrast ratio
- APCA perceptual contrast
- Using a contrast grid
- Handling dark mode
- Soften the extremes
- Re-check every pairing
- Do and dont swatch examples
- A worked example: an accessible indigo system
- Exporting your color tokens
- Why APCA improves on WCAG 2
- Designing for color blindness
- Choosing accessible status colors
- Testing your system in practice
- Preview on real components
- Check at real sizes
- Walk the whole flow
- Bringing the accessible system together
- Frequently asked questions
- What makes a color palette accessible?
- What is the difference between WCAG and APCA?
- Why do I need a full 50 to 950 scale?
- How do I check contrast across my whole palette?
- Can I just invert my light theme for dark mode?
- Why export semantic tokens instead of hex codes?
An accessible color palette is one that everyone can read, including people with low vision or color blindness. It is not a constraint that makes design worse. Done right, it makes your product clearer for every user, on every screen, in every light. The trick is to build accessibility into the system from the start, not patch it on at the end.
This guide is for designers and developers who want a color system that looks good and passes real contrast checks. I will cover why you need scales from 50 to 950, how to choose text and background pairings, how WCAG and APCA differ in plain terms, how to use a contrast grid, how to handle dark mode, the do and dont patterns to remember, and how to export your colors as tokens. Let us build it properly.
Key takeaways
| Topic | What to remember |
|---|---|
| Scales | Build 50 to 950 so every role has a shade |
| Pairings | Light text on dark, dark text on light, never mid on mid |
| WCAG | 4.5:1 body, 3:1 large or UI, 7:1 AAA |
| APCA | Perceptual Lc, aim 60+ large and 75+ body |
| Dark mode | Re-check every pairing, do not just invert |
| Tokens | Export semantic names, not raw hex |
Why color scales from 50 to 950
A brand color is a single hue, but a product needs many shades of it. You need a pale tint for a hover background, a mid shade for a border, and a deep shade for text. A scale gives you all of these from one color in a predictable ladder.
The common convention, popularized by Tailwind, labels these steps 50, 100, 200, and so on up to 950. The 50 step is nearly white and the 950 step is nearly black. Each number maps to a job, so the whole team speaks the same language.
Why even lightness matters
For a scale to be useful, the lightness should step evenly. If two adjacent shades look almost identical, you waste a step. If a jump is too big, you lose a needed shade. Even steps make accessible pairings predictable.
This is hard to do in older models like HSL, where lightness is not perceptual. It is easy in OKLCH, where the L value matches what your eye sees. The Zepixo Colors workspace generates each scale in OKLCH, so the steps stay even. Our what is OKLCH and Tailwind color scale guides explain the model.
Choosing accessible text and background pairings
Accessibility lives in the pairing, not the single color. A color is never accessible on its own, only against another. The reliable pattern is to pair shades from opposite ends of the scale.
Light backgrounds want dark text, so think 50 background with 700 or higher text. Dark backgrounds want light text, so think 900 background with 100 or 200 text. The danger zone is mid against mid, like a 400 on a 500, which almost never passes.
WCAG versus APCA in plain terms
There are two ways to measure contrast, and they answer slightly different questions. Both are worth knowing, since teams use both today.
WCAG 2 contrast ratio
WCAG 2 gives a ratio between two colors, from 1 to 1 up to 21 to 1. The thresholds are simple. Normal body text needs 4.5 to 1, large text and UI elements need 3 to 1, and the strict AAA level needs 7 to 1 for body text.
WCAG 2 is the legal and practical baseline for most products. It is widely required and well understood. Its main weakness is that it can be inaccurate at the extremes of lightness.
APCA perceptual contrast
APCA is a newer model that better matches how human eyes read text. It is perceptual, so it weighs lightness the way you actually see it. It is also polarity aware, meaning it knows that dark text on light reads differently from light text on dark.
APCA reports a value called Lc, which can be positive or negative depending on polarity. As a rough rule of thumb, aim for an absolute Lc of 60 or more for large text and 75 or more for body text. It is the contrast model planned for future WCAG versions.
| Model | Output | Body text target | Large or UI target |
|---|---|---|---|
| WCAG 2 | Ratio, 1:1 to 21:1 | 4.5 : 1 (7 : 1 for AAA) | 3 : 1 |
| APCA | Lc, perceptual | Lc 75+ | Lc 60+ |
For the full story, read our deep dives on WCAG color contrast explained and APCA contrast explained. The official APCA reference and the WCAG contrast minimum are the authoritative sources.
Using a contrast grid
Checking pairings one at a time is slow and easy to skip. A contrast grid solves this by testing every color against every other color at once. You see the whole system pass or fail in a single view.
In Zepixo Colors, the grid is 11 by 11, since each scale has 11 steps. Every cell shows the contrast between a foreground and a background shade. You can toggle the whole grid between WCAG ratio and APCA Lc with one click.
| 50 | 300 | 500 | 900 | |
| 50 | 1.0 | 1.8 | 5.9 | 14 |
| 500 | 5.9 | 2.4 | 1.0 | 2.9 |
| 900 | 14 | 7.1 | 2.9 | 1.0 |
The grid makes weak spots obvious. If a pairing you wanted to use shows red, you adjust a shade and watch the grid update. It turns accessibility from guesswork into a quick scan. See the accessibility docs for how the grid works in detail.
Handling dark mode
Dark mode is not just an inverted light theme. Pure inversion often produces harsh, glaring colors and broken contrast. You need to design the dark palette as its own system and check it.
Soften the extremes
Avoid pure black backgrounds and pure white text, since the contrast can feel harsh and cause halos. Use a very dark neutral like #0f172a for surfaces and a soft off-white like #e2e8f0 for text. The result is easier on the eyes.
Re-check every pairing
A pairing that passes in light mode can fail in dark mode. Brand colors often need a lighter shade on dark backgrounds to stay readable. Run the dark palette through the contrast grid the same way you did the light one.
Our full dark mode color palette guide covers the surface ladders and elevation tricks. Build the dark theme alongside the light one so they stay in sync.
Do and dont swatch examples
A few habits separate accessible systems from fragile ones. Here are the patterns to keep and the ones to drop.
| Do | Dont |
|---|---|
| Pair opposite ends of the scale | Pair two mid shades together |
| Test text on its real background | Judge a color in isolation |
| Add a non-color cue to status | Rely on color alone for meaning |
| Re-check pairings in dark mode | Invert the light theme blindly |
| Use semantic token names | Hard-code raw hex in components |
One pattern deserves a special note. Never use color as the only way to signal meaning, since color-blind users may miss it. Add an icon, label, or shape so the message survives without color.
A worked example: an accessible indigo system
Let us build a small system end to end. We lead with indigo #5b5bd6 and want it to pass for real UI. Here is the path.
- Generate the scale in OKLCH, from #eef2ff at 50 to #1e1b4b at 950, with even lightness steps.
- Set body text to indigo 800 #3730a3 on a 50 background #eef2ff, which clears 4.5 to 1.
- Set primary buttons to indigo 600 #4f46e5 with white text, which clears 4.5 to 1 for the label.
- For dark mode, use a #0f172a surface with indigo 300 #a5b4fc text, re-checked in the grid.
- Add success #16a34a, warning #d97706, and error #dc2626, each paired with text that passes.
Every pairing above was checked, not assumed. That is the whole discipline. When the grid is green and status has non-color cues, the system is genuinely accessible.
Ready to build yours? Open the Zepixo Colors workspace to generate OKLCH scales and check every pairing in the 11 by 11 contrast grid.
Exporting your color tokens
An accessible palette is only useful if developers can use it. That means exporting your colors as tokens with clear, semantic names. A token like color-text-primary is far better than a raw hex pasted into a component.
Semantic names also make theming easy. When the same token points to a light value in one theme and a dark value in another, dark mode becomes a switch, not a rewrite. The token carries the meaning, and the value follows the theme.
| Token | Light value | Dark value |
|---|---|---|
| color-surface | #ffffff | #0f172a |
| color-text-primary | #1e293b | #e2e8f0 |
| color-brand | #5b5bd6 | #a5b4fc |
Zepixo Colors exports to Tailwind config, CSS variables, JSON, and SCSS. You pick the format your stack uses and drop it in. See the export reference and our color design tokens guide, plus the Tailwind colors docs for the v4 OKLCH defaults.
Why APCA improves on WCAG 2
WCAG 2 has served the web well, but it has a known flaw. Its formula can rate some pairings as passing when they are hard to read, and fail others that read fine. This happens most at the light and dark extremes of a scale.
The problem is that WCAG 2 treats contrast as a simple ratio of luminance. Human vision does not work that way. We perceive lightness on a curve, and we read dark text on light backgrounds differently from light text on dark. WCAG 2 ignores that polarity entirely.
APCA was built to fix this. It models perceived lightness and accounts for polarity, so its scores track real readability more closely. That is why APCA is the contrast method being developed for future versions of WCAG. For most teams, the practical approach is to pass WCAG 2 today and also keep an eye on APCA Lc.
| Concern | WCAG 2 | APCA |
|---|---|---|
| Perceptual lightness | Approximate | Modeled |
| Polarity aware | No | Yes |
| Accuracy at extremes | Weaker | Stronger |
| Current legal baseline | Yes | Not yet |
You do not have to choose one or the other. The Zepixo contrast grid shows both, so you can satisfy today's requirement and design with tomorrow's in mind. Toggling between them takes a single click.
Designing for color blindness
Roughly one in twelve men and one in two hundred women have some form of color vision deficiency. The most common type makes red and green hard to tell apart. An accessible system has to work for these users, not just for perfect color vision.
The core rule is simple. Never use color as the only way to carry meaning. A red error and a green success look nearly identical to many users, so pair each with an icon, a label, or a shape. The color reinforces the message, but it never carries it alone.
Contrast also helps color-blind users, since lightness differences survive when hue differences do not. A pair with strong WCAG or APCA contrast stays distinguishable even when the colors blur together. This is one more reason to lead with lightness, not hue, when separating elements.
Choosing accessible status colors
Success, warning, and error colors carry critical meaning, so they deserve extra care. The conventional green, amber, and red are familiar, but the default shades often fail contrast. You usually need a darker text shade and a lighter background shade from each status scale.
Build each status color as its own scale, just like the brand color. Then pair a deep shade for text or icons with a pale shade for the background. This keeps the status readable while still feeling like the right color.
| Status | Background | Text or icon |
|---|---|---|
| Success | #dcfce7 | #166534 |
| Warning | #fef9c3 | #854d0e |
| Error | #fee2e2 | #991b1b |
Each pairing above clears the 4.5 to 1 body-text threshold. That is the point of building status colors as scales. You get the familiar hue and the contrast you need, rather than fighting a single default shade that fails.
Testing your system in practice
Contrast math is necessary but not sufficient. A palette can pass every grid cell and still feel off on real components. The final check is always to see your colors on actual buttons, cards, and forms.
Preview on real components
Colors behave differently on a filled button than on a thin border. Preview your system on real UI before you lock it. The Zepixo Colors workspace renders your palette on buttons, cards, and inputs so you catch problems the grid cannot show.
Check at real sizes
Large text needs less contrast than small text, which is why the thresholds differ. Test your body size and weight, not just a big sample. A color that reads fine in a heading can fail in a 14 pixel caption.
Walk the whole flow
Click through a real screen and watch hover, focus, and disabled states. These states often get skipped and then fail contrast in production. An accessible system covers every state, not just the resting one.
Bringing the accessible system together
An accessible color system is a sequence of small, checkable decisions. Build even scales in OKLCH, pair opposite ends, verify with WCAG or APCA, scan the contrast grid, design dark mode on purpose, and export semantic tokens. None of these steps is hard on its own.
The payoff is a product that reads clearly for everyone and a palette your team can trust. You stop guessing whether a pairing works and start knowing. For the broader theory behind the colors, see color theory for branding, and for the model details, the colors overview.
Frequently asked questions
What makes a color palette accessible?
An accessible palette provides enough contrast between text and its background for everyone to read. It meets WCAG or APCA thresholds and never relies on color alone to convey meaning. Building it on even scales makes those pairings predictable.
What is the difference between WCAG and APCA?
WCAG 2 gives a contrast ratio with fixed thresholds like 4.5 to 1 for body text. APCA is a newer perceptual model that reports an Lc value and accounts for lightness and polarity. WCAG is today's baseline, while APCA is more accurate and planned for future standards.
Why do I need a full 50 to 950 scale?
A single hex cannot cover backgrounds, borders, text, and hover states. A scale gives you a shade for each job in a predictable ladder. Even lightness steps make it easy to find pairings that pass contrast.
How do I check contrast across my whole palette?
Use a contrast grid that tests every color against every other color at once. The Zepixo grid is 11 by 11 and toggles between WCAG ratio and APCA Lc. Green cells pass, so you can scan the whole system in seconds.
Can I just invert my light theme for dark mode?
No, pure inversion usually produces harsh colors and broken contrast. Design the dark palette with softened extremes, like a dark neutral surface and an off-white text. Then re-check every pairing in the contrast grid.
Why export semantic tokens instead of hex codes?
Semantic tokens carry meaning, like color-text-primary, so themes can swap values without rewrites. They keep components consistent and make dark mode a simple switch. Zepixo exports them as Tailwind, CSS variables, JSON, or SCSS.
Build even scales, pair them wisely, verify with the grid, and ship tokens. Do that and your colors will work for everyone. You have got this.
Shaheer Malik
Founder of Zepixo — building the whole brand studio in one tab. Try Zepixo →