Design system color · WCAG 2.2 · APCA

OKLCH + APCA color systems, end to end

Updated May 2026Reviewed by Khushwant Parihar, CPACC

The complete blueprint for shipping a color system that's perceptually consistent, accessible under both WCAG 2.2 and the APCA draft, survives color-vision deficiency, and exports to every platform you ship to.

~12 min read

OKLCH solves what HSL never could

HSL has been the workhorse of color manipulation on the web since the late 1990s. It's mathematically simple — a hue angle, a saturation percentage, a lightness percentage — and it powers virtually every “lighten 10%” preprocessor function ever shipped. It has one flaw that everyone ignores: HSL lightness is not perceptually uniform.

Plot HSL hsl(60, 80%, 50%) (yellow) next to hsl(240, 80%, 50%) (blue) at the same lightness value. They look nothing alike in brightness — the yellow burns your retinas while the blue retreats into the page. This is the same reason a Tailwind v3 palette generated naïvely in HSL produced inconsistent middle stops across hues, which is exactly why Tailwind v4 moved to OKLCH.

OKLCH (Björn Ottosson, 2020) is a transform of OKLab into cylindrical lightness-chroma-hue coordinates. Crucially, its lightness channel maps to perceived brightness — two OKLCH colors with the same L value look equally bright regardless of hue. The 500 stops across primary, secondary, accent, success, warning, danger, and info all feel like they belong on the same step of the same staircase.

The 11-stop perceptual scale

Eleven stops (50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950) is the de-facto modern standard. It aligns with Tailwind, Radix Colors, shadcn/ui, and most design-system token libraries. Each stop targets a specific OKLCH L value drawn from a perceptually-curved ramp:

StopOKLCH LTypical role
500.985Subtle tint backgrounds, hover wash
1000.965Soft surfaces, tag fills
2000.925Disabled fills, divider tints
3000.870Borders, faint outlines
4000.780Muted iconography, large display
5000.680Mid-tone — brand anchor
6000.585Default primary button (light mode)
7000.500Hover state for primary buttons
8000.420Active / pressed states
9000.340Dark foreground text
9500.230Inky deep mode background

Chroma follows a separate curve — it peaks around the middle stops (400–500) where saturation reads strongest, and tapers toward the extremes so 50 doesn't blow out and 950 doesn't feel muddy. Every generated stop is then gamut-mapped into displayable sRGB so the browser actually renders the color the math produced.

APCA vs WCAG 2.2 — what to grade against

WCAG 2.2's contrast ratio (1.4.3, 1.4.6) is the legal baseline. Body text needs 4.5:1, large text needs 3:1, non-text UI components need 3:1. The model is the same for dark text on light backgrounds as it is for light text on dark — a single symmetric luminance ratio.

That symmetry is APCA's biggest objection. Light text on dark backgrounds is perceived differently from dark on light (Stevens' power law, irradiance asymmetry, decades of human factors research). APCA computes Lc — a signed value approximately between −108 and +106 — that respects polarity. Negative Lc means light text on dark; positive means dark on light. The absolute value gets checked against thresholds:

Use caseAPCA Lc minimumEquivalent WCAG
Body text (fluent reading)754.5 : 1
Body text (preferred)907 : 1
Large text (24px+ or 18.66px bold)603 : 1
UI components (buttons, fields)603 : 1
Focus rings, dividers, icons453 : 1
Spot text / decorative30no minimum

Our recommendation: pass WCAG 2.2 contractually, target APCA Bronze Simple as a forward-compatible bonus. The Studio displays both side-by-side so any pairing that's marginal in one model gets flagged.

Color-vision deficiency — the collapse you can't see

Approximately 8% of men and 0.5% of women have some form of color-vision deficiency. The three dichromacies — protanopia (no red cones), deuteranopia (no green cones), and tritanopia (no blue cones) — collapse hue differences that look obvious to typical vision. Success-green and danger-red are the textbook example: in deuteranopia, both reduce to muddy brown-yellow tones that are nearly indistinguishable.

The fix is twofold. First, never rely on color alone to convey information (WCAG 1.4.1) — pair the color with an icon, text label, or shape. Second, run your semantic colors through the Machado, Oliveira & Fernandes (2009) transforms and check that the pairs that need to be distinguishable still are, computed as ΔE-OK perceptual distance. The Palette Studio runs this check automatically and surfaces a warning when any semantic pair collapses below the distinguishability threshold.

One source of truth, every platform

Design tokens exist to keep the same hex value flowing through Figma, Tailwind, vanilla CSS, iOS, and Android. The W3C Design Tokens Community Group (DTCG) format is the emerging standard for that source of truth. From DTCG JSON you can transform to:

  • Tailwind v4@theme block with --color-* custom properties
  • Tailwind v3tailwind.config.ts with theme.extend.colors
  • Vanilla CSS:root variables plus [data-theme="dark"] override
  • Tokens StudioFigma plugin import for design parity
  • SwiftUIColor extension for UIKit / SwiftUI
  • Kotlin ComposeColor object for Android Jetpack

Build the transforms into your design-system release pipeline. When a color shifts, every consumer updates from the same commit. The Studio's export panel produces all of the above in one click.

Step-by-step workflow

1

Pick a base color in OKLCH, not HSL

Convert your brand hex to OKLCH. Note the L (lightness), C (chroma), and H (hue). OKLCH lightness is perceptually uniform — two colors at L=0.6 look equally bright to the human eye regardless of hue.

2

Generate 11 stops with a curved lightness ramp

Target L values that step evenly through the perceptual range — roughly 0.985, 0.965, 0.925, 0.870, 0.780, 0.680, 0.585, 0.500, 0.420, 0.340, 0.230 for stops 50 through 950. Chroma peaks around the middle stops and tapers at extremes.

3

Gamut-map each stop to displayable sRGB

OKLCH can describe colors outside the sRGB gamut. Use chroma clamping to bring each generated stop back into a renderable color while preserving lightness and hue.

4

Derive semantic tokens from scales

Map primary-600 to the rest-state of buttons, primary-700 to hover, primary-100 to soft surfaces. Define background, foreground, border, focus, success, warning, danger, and info as token references — never raw hex.

5

Grade every pairing with both WCAG and APCA

Body text on background, button text on button, focus ring on adjacent surface, disabled state, alert filled, alert tinted. Pass WCAG 2.2 contractually and check APCA Lc as forward-compatibility insurance.

6

Verify under color-vision deficiency

Run the palette through protanopia, deuteranopia, tritanopia, and grayscale simulations. Watch especially for success/danger collapse — that's the classic red-green failure.

7

Export tokens to every consumer

Ship the same source of truth as Tailwind theme, CSS variables, W3C DTCG JSON, Tokens Studio JSON for Figma, Swift UIColor, and Compose Color. Token references — not hex strings — keep all platforms in sync.

FAQ

Why OKLCH instead of HSL?

HSL is mathematically convenient but not perceptually uniform — two HSL colors with the same L value can look dramatically different in actual brightness. OKLCH (Björn Ottosson, 2020) is a perceptual color space derived from OKLab. An 11-stop scale generated in OKLCH steps evenly to the human eye across all hues; the same scale in HSL produces obvious bright spots and dark spots.

What is APCA and how does it differ from WCAG 2.2?

APCA (Accessible Perceptual Contrast Algorithm) is the contrast model proposed for WCAG 3. It computes Lc, a signed value typically between −108 and +106, that accounts for font size, font weight, and polarity (dark text on light vs light text on dark). WCAG 2.2 uses a luminance ratio (4.5:1 for body text) that does not distinguish polarity, so combinations that score 4.7:1 can still fail readability in dark mode — a problem APCA explicitly addresses.

Should I ship APCA today?

Use APCA as a secondary check, not a primary contractual baseline. WCAG 2.2 is the legal floor in most jurisdictions (Section 508, ADA Title II in the United States; EAA in Europe; AODA in Canada). APCA Bronze Simple thresholds (Lc ≥ 75 body, Lc ≥ 60 large, Lc ≥ 45 non-text) are the forward-compatible target that future-proofs your palette.

Why 11 stops? Why not 10 or 12?

Eleven stops (50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950) align with Tailwind, Radix, and shadcn conventions, so the output drops into existing ecosystems without renaming. The extra 50 and 950 stops give you a near-white tint and an inky deep stop without forcing you to choose between them.

How do I handle dark mode without inverting?

Don't invert. Use lighter stops (400–500) for primary surfaces in dark mode, darker stops (50–100) for the corresponding foregrounds. Each role — primary, secondary, accent, success, warning, danger, info — needs its own light-mode and dark-mode pair. Both should pass WCAG and APCA independently. Inverting a light palette produces dark mode that looks correct in marketing screenshots but fails real reading sessions.

What about color-vision deficiency?

Around 8% of men and 0.5% of women have some form of color-vision deficiency. The most common is deuteranopia (no green cones). Test your palette under all three dichromacies using Machado et al. (2009) matrices: protanopia, deuteranopia, and tritanopia. The classic failure is success (green) collapsing into danger (red) under deutan or protan. Pair color with iconography, text, or shape to satisfy WCAG 1.4.1 (Use of Color).

How do I export tokens once and use them everywhere?

Start with W3C Design Tokens Community Group JSON (DTCG) as your source of truth. From there, transform into platform-specific formats: Tailwind config (or v4 @theme block), CSS variables, Tokens Studio JSON for Figma, Swift UIColor extension, Kotlin Color object for Compose. Build the transform into your design-system release pipeline so every consumer gets the same hex value from a single edit.

Where does this end up under WCAG 3?

WCAG 3 is still a working draft. APCA is currently the contrast model under consideration, but the final spec may change. The safe strategy: pass WCAG 2.2 today, design with OKLCH and APCA so you're ready for whichever direction WCAG 3 goes, and version your design tokens so you can re-grade if requirements shift.

Build your color system in the Studio

Apply every step of this guide in one click: OKLCH 11-stop scales, WCAG + APCA grading, color-blindness simulation, state-aware report, and token exports to every platform.