Color that looks great and fails contrast checks is design debt. By the end of this guide, you will build a small, reusable palette system that exposes accessible color tokens, previews real contrast outcomes, and scales across themes. You will learn how to structure the HTML for swatches, declare tokens with OKLCH for predictable lightness, choose on-color text automatically, and label contrast results in a way a team can ship with confidence.
Why How to Create an Accessible Color Palette Matters
Picking pretty hex codes is not a system. Accessibility turns colors into reliable UI decisions: text that remains readable, interactive states that stand out, and data visualizations that still communicate without hue alone. A palette that encodes contrast guidance up front saves time, reduces regressions, and prevents last-minute redesigns. You will use modern CSS color spaces to get smoother ramps, use CSS variables to make tokens portable, and provide authors with clear labels, so components can inherit safe defaults automatically.
Prerequisites
You only need core web skills. We will keep it HTML and CSS so you can drop this into any stack and extend it later with build tooling or design tokens if you wish.
- Basic HTML
- CSS custom properties
- CSS pseudo-elements (::before / ::after)
Step 1: The HTML Structure
The markup creates a palette grid. Each swatch exposes a human name, the token name, the color value, a live preview chip, sample text, and a contrast badge. The badge text is readable, and the chip is decorative, so it uses aria-hidden. Tokens are referenced through inline style variables for clarity in the tutorial, though you can map them through classes or data attributes in production.
Brand palette (accessible preview)
Brand 50
--brand-50
#eef2ff
Sample text Aa Bb 123
AAA
Brand 100
--brand-100
#e0e7ff
Sample text Aa Bb 123
AAA
Brand 200
--brand-200
#c7d2fe
Sample text Aa Bb 123
AA
Brand 300
--brand-300
#a5b4fc
Sample text Aa Bb 123
AA Large
Brand 400
--brand-400
#818cf8
Sample text Aa Bb 123
AA
Brand 500
--brand-500
#6366f1
Sample text Aa Bb 123
AAA
Brand 600
--brand-600
#4f46e5
Sample text Aa Bb 123
AAA
Brand 700
--brand-700
#4338ca
Sample text Aa Bb 123
AAA
Step 2: The Basic CSS & Styling
We start with a token layer. Hex fallbacks load first, then an OKLCH upgrade through @supports. OKLCH gives you even spacing by lightness, which makes contrast planning reliable. The grid lays out the swatches, and a progressive color-contrast() sets readable text on each swatch if the browser supports it.
/* CSS */
:root {
/* Hex fallbacks for broad support */
--brand-50: #eef2ff;
--brand-100: #e0e7ff;
--brand-200: #c7d2fe;
--brand-300: #a5b4fc;
--brand-400: #818cf8;
--brand-500: #6366f1;
--brand-600: #4f46e5;
--brand-700: #4338ca;
--text-strong: #111111;
--text-inverse: #ffffff;
--surface: #0b0b0c;
--bg: #ffffff;
--muted: #6b7280;
--radius: 12px;
}
@supports (color: oklch(98% 0.03 255)) {
:root {
/* Same tokens in OKLCH for smoother ramps */
--brand-50: oklch(98% 0.03 255);
--brand-100: oklch(95% 0.04 255);
--brand-200: oklch(90% 0.06 255);
--brand-300: oklch(80% 0.08 255);
--brand-400: oklch(70% 0.10 255);
--brand-500: oklch(60% 0.12 255);
--brand-600: oklch(50% 0.10 255);
--brand-700: oklch(42% 0.08 255);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html, body {
height: 100%;
}
body {
margin: 0;
font: 16px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji", "Segoe UI Emoji";
background: var(--bg);
color: var(--surface);
}
.palette {
max-width: 1100px;
margin: 2rem auto;
padding: 0 1rem;
}
.palette > h2 {
margin: 0 0 1rem;
font-size: 1.25rem;
}
.swatch {
display: grid;
grid-template-columns: 56px 1fr auto;
align-items: center;
gap: 1rem;
padding: 0.875rem 1rem;
border-radius: var(--radius);
background: color-mix(in oklab, var(--swatch) 14%, white);
border: 1px solid color-mix(in oklab, var(--swatch) 30%, black);
transition: box-shadow .2s ease, transform .1s ease;
}
/* Fallbacks for color-mix */
@supports not (background: color-mix(in oklab, red 50%, white)) {
.swatch {
background: #ffffff;
border-color: #e5e7eb;
}
}
.swatch + .swatch {
margin-top: .75rem;
}
.swatch__label {
display: grid;
gap: 0.125rem;
}
.swatch__name {
display: block;
font-weight: 600;
}
.swatch__token,
.swatch__value {
font-size: 0.8125rem;
color: var(--muted);
}
.swatch__sample {
grid-column: 1 / -1;
margin-left: calc(56px + 1rem);
padding: .375rem .5rem;
border-radius: 6px;
background: var(--swatch);
color: var(--text-strong);
}
/* Progressive readable on-color text for the sample */
@supports (color: color-contrast(white vs black)) {
.swatch__sample {
color: color-contrast(var(--swatch) vs #111111, #ffffff);
}
}
.badge {
justify-self: end;
font-size: .75rem;
font-weight: 700;
color: var(--text-inverse);
background: var(--surface);
padding: .25rem .5rem;
border-radius: 999px;
position: relative;
}
Advanced Tip: Keep tokens semantic (brand-500, success-700) rather than physical (blue, green). Semantic tokens can point at different ramps under a theme switch without breaking component styles.
Step 3: Building the Swatch Chip
The chip provides an immediate visual of the token color in a compact shape. We will make it circular, add an inset ring for contrast against light and dark swatches, and keep it decorative with aria-hidden. If you want a refresher on making circles with pure CSS, see this short guide on the CSS circle.
/* CSS */
.swatch__chip {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--swatch);
box-shadow:
inset 0 0 0 2px color-mix(in oklab, var(--swatch) 25%, white),
0 0 0 1px color-mix(in oklab, var(--swatch) 30%, black / 40%);
}
/* Fallback for color-mix-based rings */
@supports not (background: color-mix(in oklab, red 50%, white)) {
.swatch__chip {
box-shadow:
inset 0 0 0 2px #ffffff,
0 0 0 1px rgba(17,17,17,.12);
}
}
/* Optional hover lift for clarity */
.swatch:focus-within,
.swatch:hover {
box-shadow: 0 6px 22px rgba(17, 17, 17, .14);
transform: translateY(-1px);
}
How This Works (Code Breakdown)
Each swatch receives the color through the –swatch variable, so the same CSS draws every chip. The chip uses border-radius: 50% to become circular. The inset ring prevents the chip from blending into very light or very dark backgrounds. color-mix blends the swatch toward white for the inner ring and toward black for the outer hairline, which stabilizes contrast around the edge. If color-mix is not supported, the fallback uses plain white and a subtle rgba stroke.
Using a consistent size keeps the geometry equal across tokens, which makes comparisons faster. The hover lift creates a sense of depth without relying on color alone. The rest of the swatch text lives outside the chip so screen readers announce the name and value cleanly.
If you prefer to show the chip as a pointed label, you can add a little notch using a CSS triangle. Here is a reference for shaping the pointer end with a simple border trick: CSS triangle.
Step 4: Building the Contrast Badge
The badge labels the swatch with the intended usage level: AAA, AA, or “AA Large” for 18.66px bold or 24px regular. It sits on the right, reads as a pill, and includes a small decorative notch so designers spot it quickly. The notch is a triangle built with borders, and the text uses a high-contrast foreground on a dark surface.
/* CSS */
.badge {
--badge-bg: #111111;
--badge-fg: #ffffff;
position: relative;
display: inline-flex;
align-items: center;
gap: .25rem;
padding: .25rem .625rem .25rem .625rem;
line-height: 1;
background: var(--badge-bg);
color: var(--badge-fg);
border-radius: 999px;
}
/* Tiny notch using a triangle */
.badge::after {
content: "";
position: absolute;
right: -6px;
top: 50%;
transform: translateY(-50%);
border: 6px solid transparent;
border-left-color: var(--badge-bg);
}
/* Respect motion preferences on hover animations defined elsewhere */
@media (prefers-reduced-motion: reduce) {
.swatch:hover {
box-shadow: none;
transform: none;
}
}
How This Works (Code Breakdown)
The badge uses a simple token pair for background and foreground that always pass. The notch is a zero-size element that shows a right-pointing triangle by giving the left border a color and keeping the other borders transparent. This keeps the markup clean while giving the label a distinctive silhouette. If you want a more involved label shape for pricing or tags, take a look at the pattern here: CSS price tag. It uses the same triangle principle for its cutout.
role=”status” on the badge announces the contrast level for screen readers without treating it like a button or link. Keep the label short, and add an aria-label that explains the grade in plain language. This helps a QA pass confirm the claim against WCAG levels during audits.
Advanced Techniques: Adding Pairings and a Hover Tooltip
A palette is stronger when it shows safe foregrounds on top of each swatch. We will add a tooltip that previews foreground on the color with a short label and a pointer. The tooltip only supplements the badge, and it stays hidden from assistive tech with aria-hidden because the badge already communicates the info.
/* CSS */
.swatch {
position: relative;
}
.swatch[data-tip]:hover::before,
.swatch[data-tip]:hover::after {
--tip-bg: var(--swatch);
--tip-fg: #111111;
}
@supports (color: color-contrast(white vs black)) {
.swatch[data-tip]:hover::before,
.swatch[data-tip]:hover::after {
--tip-fg: color-contrast(var(--swatch) vs #111111, #ffffff);
}
}
/* Bubble */
.swatch[data-tip]::before {
content: attr(data-tip);
position: absolute;
left: calc(56px + 1rem);
top: -.5rem;
transform: translateY(-100%);
font-size: .75rem;
padding: .35rem .5rem;
border-radius: .5rem;
background: var(--tip-bg);
color: var(--tip-fg);
box-shadow: 0 8px 20px rgba(17,17,17,.18);
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity .15s ease, transform .15s ease;
}
/* Arrow */
.swatch[data-tip]::after {
content: "";
position: absolute;
left: calc(56px + 1rem + .75rem);
top: -.5rem;
transform: translateY(-2px);
border: 6px solid transparent;
border-top-color: var(--tip-bg);
pointer-events: none;
opacity: 0;
transition: opacity .15s ease, transform .15s ease;
}
.swatch:hover::before,
.swatch:hover::after {
opacity: 1;
transform: translateY(-110%);
}
/* Example attribute applied to show the tooltip */
.swatch:nth-child(4) {
/* Show a preview tip on one swatch as an example */
}
The tooltip appears above the swatch and uses the same on-color logic as the sample text, so it stays readable. The pointer is a triangle made with CSS borders, just like the badge notch, and keeps the markup clean. If you want an end-to-end pattern for speech bubbles and pointers, see this handy reference for a tooltip shape.
Accessibility & Performance
Accessibility
Test contrast with a reliable checker across body text and UI affordances. Aim for 4.5:1 for normal text and 3:1 for large text and iconography. Keep a dedicated on-color token per swatch if you need deterministic results without relying on color-contrast(). For example, define –on-brand-500 and reference it in components that use brand-500 as a background. Progressive on-color selection is helpful, but you should not block shipping on it.
Do not rely on color alone to convey status. Pair a state color with an icon or label. Provide a text label for badges through aria-label so assistive tech does not have to guess the meaning of “AA.” Decorative parts like chips and triangle pointers should remain aria-hidden.
Motion on hover should remain subtle, and it should go quiet under prefers-reduced-motion. Keep keyboard focus visible on any interactive extension you add later, such as copy-to-clipboard buttons or tone toggles.
Performance
This approach is CSS-only and renders fast. OKLCH and color-mix are computed on the compositor efficiently. color-contrast() is also fast and avoids layout thrash. Avoid heavy shadows or animated blurs for large grids of swatches. Keep the DOM small: one element per piece of information is enough. Tooltips that use pseudo-elements do not create extra nodes, so the tree stays lean.
Ship palettes you can trust
You built a color system that carries its own guidance. Tokens live in CSS variables, tints stay smooth with OKLCH, foreground color picks itself when possible, and badges explain safe usage levels. Now you can grow the palette with neutrals, status colors, and dark mode mirrors while keeping the same structure. This gives every component a dependable base to read clearly and pass audits.