Ensuring High Color Contrast: CSS Techniques for WCAG 2.2 Conformance

How to meet WCAG 2.2 color contrast requirements with modern CSS, using oklch(), prefers-color-scheme, and color-mix() to maintain AA and AAA ratios.

You are debugging a dark mode toggle at 2 a.m. The text you swore was #fff over #1a1a1a just failed the contrast check in DevTools. The fix is not to bump the opacity. Stop using opacity for text. Reach for a color function that keeps luminance stable. This guide covers CSS color contrast accessibility, the exact mechanics of WCAG 2.2 contrast ratios, and the modern CSS features that make conformance reproducible instead of eyeballed: oklch(), color-mix(), and prefers-color-scheme. You need a reliable reference, not a 2014 StackOverflow answer, so here is the modern way.

WCAG 2.2 Contrast Ratio: The Numbers That Matter

Contrast Minimum And Enhanced Ratios

WCAG 2.2 Success Criterion 1.4.3 (Contrast Minimum) sets the floor for normal text at a contrast ratio of 4.5:1 against the backdrop. Large text gets 3:1. Large text means 18px bold or 24px regular, per the WCAG 2.2 specification. Success Criterion 1.4.6 (Contrast Enhanced) raises the bar to 7:1 for normal text and 4.5:1 for large text. That is the AAA level. The contrast ratio formula is (L1 + 0.05) / (L2 + 0.05), where L1 is the lighter relative luminance and L2 the darker. Relative luminance is not a human perception of brightness. It is a weighted sum of linearized RGB channels.

Why hsl() Fails And oklch() Wins

That is why hsl() fails you. A saturated yellow and a pale gray can share the same perceived lightness but produce wildly different luminance values. The ratio you compute from hsl() is a guess. oklch() changes this. It is perceptually uniform. The same numerical change in lightness looks like the same visual change. Predicting contrast ratios from the L value becomes practical. The WCAG 2.2 contrast ratio you write should never rely on a hex value’s appearance. Rely on the computed luminance. oklch() gives you a lever to adjust that luminance predictably.

Here is a complete, runnable dark-mode-aware color scheme using oklch() and prefers-color-scheme that maintains AA contrast. Define your palette as custom properties in oklch(). Then set the text and backdrop so the contrast ratio always clears 4.5:1 for body text and 3:1 for large text. The L value in oklch() maps to luminance directly enough that you can choose L values known to pass.

:root {
  --text: oklch(0.95 0.02 250); /* near-white, low chroma */
  --bg: oklch(0.20 0.03 250);   /* near-black, low chroma */
  --text-large: oklch(0.98 0.01 250);
  --accent: oklch(0.70 0.15 30); /* for non-text UI */
}

@media (prefers-color-scheme: light) {
  :root {
    --text: oklch(0.15 0.02 250);
    --bg: oklch(0.97 0.005 250);
    --text-large: oklch(0.10 0.02 250);
    --accent: oklch(0.45 0.15 30);
  }
}

body {
  color: var(--text);
  background-color: var(--bg);
  font-size: 1rem; /* normal text needs 4.5:1 */
}

h1, .large {
  color: var(--text-large);
  font-size: 1.5rem; /* 24px, qualifies as large text */
  font-weight: 400;
}

button {
  color: var(--bg);
  background-color: var(--accent);
  border: 0;
  padding: 0.5rem 1rem;
}

/* Interactive states need 3:1 against adjacent colors, not just the bg */
button:focus-visible {
  outline: 2px solid var(--text);
  outline-offset: 2px;
}

This works because the L values sit far apart. 0.95 versus 0.20 gives a luminance ratio well above 7:1. The accent is used only for non-text UI, where WCAG 2.2 requires 3:1 against the backdrop for the component boundary. To check a specific pair, compute the ratio from the relative luminance. A browser DevTools contrast checker will do it for you. The WebAIM contrast checker is a reliable external tool.

The Common Failure: Semi-Transparent Text Over A Variable Background

The Opacity Trap

Here is the mistake that breaks accessibility more than any other. You apply opacity to text and assume the contrast ratio matches what you measured against the page’s base backdrop. The WCAG 2.2 contrast ratio is computed between the text’s rendered color and the actual background behind it. Use opacity: 0.5 on white text over a backdrop that changes, a hero image, a hover state, a gradient, and the effective text color becomes a blend between white and whatever sits underneath. That blend can drop below 4.5:1 in seconds. The rule: never use opacity on text. Compute the blended color you want and declare it directly.

Failure And Fix In Code

Here is a runnable example of the failure and the fix:

/* Failure: text is white, but opacity 0.5 makes it gray over a dark image */
.hero-text {
  color: #fff;
  opacity: 0.5; /* actual color is #808080 over black, ratio ~3.5:1, fails */
  background-image: url('mountains.jpg');
}

/* Fix: use a solid color that is actually readable, no opacity */
.hero-text-fixed {
  color: #f0f0f0; /* near-white, no transparency */
  background-image: url('mountains.jpg');
  /* still risky if image varies; add a solid overlay */
}

.hero-with-overlay {
  background: linear-gradient(rgba(0,0,0,0.6), rgba(0,0,0,0.6)), url('mountains.jpg');
  color: #fff; /* white over the darkened overlay: ratio depends on overlay, usually >7:1 */
}

Variable Backgrounds And The Solid Scrim

The transparent background issue also applies to background colors with alpha, a pastel tint over an image. Compute the contrast ratio against the final blended background, not the tint alone. When you must use a backdrop that varies, the reliable pattern is to place a solid color behind the text, a card or a scrim, and ensure that solid color passes the 4.5:1 ratio against the text. Text over image contrast is a specific case of this. The image is the background. You cannot control its luminance, so you control it with an overlay or a solid panel. If the backdrop is truly unknowable at authoring time, the only safe answer is to use the forced-colors media query or system colors. That is a separate topic.

Accessible Color Palette: Generating Tints With color-mix()

Solid Tints Without Opacity

The color-mix() function lets you create accessible tints without hand-picking each shade. It mixes two colors in a specified color space and returns a single color. This is useful for building an accessible color palette that maintains contrast across variants. To generate a lighter tint of a primary color for hover states, mix it with white in the oklch space. The critical difference from using opacity is that color-mix() produces a solid color. No transparency. The contrast ratio is deterministic against a known background.

Link States And The Lightening Trap

Here is a complete sample that generates a palette and uses it for link states:

:root {
  --primary: oklch(0.55 0.20 25);
  --primary-hover: color-mix(in oklch, var(--primary) 80%, white);
  --primary-active: color-mix(in oklch, var(--primary) 60%, black);
  --bg: oklch(0.97 0.01 25);
  --text: oklch(0.20 0.02 25);
}

a {
  color: var(--primary);
  text-decoration: underline;
}

a:hover {
  color: var(--primary-hover); /* lighter, still must contrast 4.5:1 against bg */
}

a:active {
  color: var(--primary-active);
}

/* But mixing with white can reduce contrast against a light background! */
/* So verify each variant against the actual bg */
@media (prefers-contrast: more) {
  a {
    color: color-mix(in oklch, var(--primary) 40%, black);
  }
}

The trap: mixing toward white reduces the luminance difference from a light background. The ratio can drop below 4.5:1. Mix toward black for hover on light backgrounds, or choose a mixing strategy that keeps the L value within a safe band. color-mix() is not a magic bullet. It is a tool that requires you to check the resulting ratio. For non-text UI elements, WCAG 2.2 requires 3:1 against adjacent colors for the component boundary. A hover state that changes a button’s fill must maintain that ratio against the page background, not just against the button’s own color. Define your palette in oklch() and use color-mix() to generate tints. Then test with a contrast checker. For a design-system author, this is the difference between shipping a palette that passes and shipping one that fails on the first user-resized text.

Dark Mode Contrast: System Preferences And Both Ends

Symmetric Palettes With oklch()

Dark mode contrast is not just flipping colors. You must ensure the contrast ratio holds in both light and dark themes. The prefers-color-scheme media query lets you respond to the user’s system setting. Design both palettes to meet WCAG 2.2. The common failure: a dark theme uses pure white text on pure black and passes easily, but the light theme uses gray-on-white and fails. The oklch color space helps. Choose L values that are symmetric. If your light theme text is oklch(0.15) and the background is oklch(0.97), the dark theme can use text at oklch(0.95) and a background at oklch(0.20). The ratio is roughly the same because the luminance difference is similar.

Handling prefers-contrast And color-scheme

Here is a complete dark mode contrast sample that also handles the prefers-contrast media query:

:root {
  --text: oklch(0.15 0.02 250);
  --bg: oklch(0.97 0.005 250);
}

@media (prefers-color-scheme: dark) {
  :root {
    --text: oklch(0.95 0.02 250);
    --bg: oklch(0.20 0.03 250);
  }
}

@media (prefers-contrast: more) {
  :root {
    --text: oklch(0.10 0.01 250);
    --bg: oklch(0.99 0 250);
  }
  @media (prefers-color-scheme: dark) {
    :root {
      --text: oklch(0.99 0 250);
      --bg: oklch(0.10 0.01 250);
    }
  }
}

body {
  color: var(--text);
  background-color: var(--bg);
}

/* The color-scheme property tells the browser to render form controls and scrollbars with the correct light/dark defaults */
html {
  color-scheme: light dark;
}

/* For non-text UI, use a separate custom property that also changes */
:root {
  --ui-border: oklch(0.55 0.10 250);
}
@media (prefers-color-scheme: dark) {
  :root {
    --ui-border: oklch(0.75 0.10 250);
  }
}

input, select, button {
  border: 1px solid var(--ui-border); /* needs 3:1 against adjacent */
}

The prefers-contrast media query is a separate axis from prefers-color-scheme. You can have dark mode with more contrast. The color-scheme property is also widely available. It ensures native controls match the theme, preventing a bright white checkbox on a dark background. The contrast ratio for non-text UI is 3:1 against adjacent colors. A border must contrast against both the page background and the element’s fill. This is where many dark themes fail. They choose a border that contrasts against the dark background but blends with the button’s fill color.

Oklch Accessible Color System: Why Perceptual Uniformity Wins

Luminance You Can Reason About

The oklch accessible color system is a deliberate choice for accessibility. oklch() is perceptually uniform. A change of 0.05 in the L (lightness) channel produces the same perceived change in lightness regardless of where you start. hsl() is perceptually uneven. A 10% lightness change in the middle of the range looks much larger than a 10% change near the ends. This matters for contrast because WCAG’s relative luminance is a linear approximation, but your eye’s perception is not linear. Design with oklch() and you can reason about contrast in terms of L values. Keep your text L below 0.3 and your background L above 0.8, and you will exceed 7:1. That is a heuristic, not a guarantee, but it is far more reliable than eye-balling hex values. oklch() also supports a wider gamut than sRGB, which future-proofs your palette for wide-gamut displays. Do not assume the browser will clamp correctly. Test on a device that supports the full gamut.

Relative Color Syntax For Derived Variants

Build a color system around oklch() that meets WCAG 2.2. Define semantic tokens that reference L values, not raw hexes. Then use relative color syntax to derive variants, such as a hover state that darkens the L value by a fixed amount. Relative color syntax is the CSS feature that lets you write oklch(from var(--primary) calc(L - 0.1) C H) to create a darker shade. This is more maintainable than color-mix() for some cases because you are not mixing with black or white, which can shift the hue.

:root {
  --primary: oklch(0.60 0.18 30);
  --primary-dark: oklch(from var(--primary) calc(L - 0.15) C H);
  --primary-light: oklch(from var(--primary) calc(L + 0.15) C H);
  --bg: oklch(0.97 0.01 30);
  --text: oklch(0.20 0.02 30);
}

/* Use --primary for links, --primary-dark for hover in light mode */
a { color: var(--primary); }
a:hover { color: var(--primary-dark); }

/* In dark mode, use the light variant for links */
@media (prefers-color-scheme: dark) {
  :root {
    --bg: oklch(0.20 0.02 30);
    --text: oklch(0.95 0.01 30);
  }
  a { color: var(--primary-light); }
  a:hover { color: var(--primary); }
}

Fallbacks For Older Browsers

The failure mode: using relative color syntax without a fallback. The syntax oklch(from var(--primary) calc(L - 0.15) C H) is parsed only by engines that support it. Ship it without a preceding fallback declaration and older browsers drop the rule entirely. Write the fallback first, then the relative color syntax. Test on the oldest browser you support. The Baseline status for relative color syntax is not settled across all engines. Check the caniuse entry for css-color-relative to see the real gap. It is often the small fraction of users on older device-locked browsers, iOS Safari on unsupported devices or Android WebView in apps that do not update.

FAQ: Color Contrast In CSS

What is the exact contrast ratio for WCAG 2.2 AA normal text?

4.5:1 for normal text, per Success Criterion 1.4.3. For large text (18px bold or 24px regular), it is 3:1.

Can I use opacity to make text lighter and still pass contrast?

No. Opacity blends the text with the background, so the effective color changes. Calculate the contrast ratio against the actual rendered background, not the original color. Use color-mix() or relative color syntax to create a solid color that matches the intended blend.

Does oklch() guarantee WCAG contrast?

No color function guarantees contrast without checking. oklch() makes it easier to predict because L is perceptually uniform, but you still need to compute the ratio using the relative luminance formula. Verify each pair with a contrast checker.

What is the difference between color-contrast() and contrast ratio?

The color-contrast() function, from CSS Color Module Level 6, is not shipped in any engine. Blink, WebKit, and Gecko all lack support. It would let you pick the color with the highest contrast from a list. Until it ships, choose colors manually and check the ratio. Do not use it without a fallback.

How does forced-colors affect my contrast?

The forced-colors media query lets you adjust styles when the user forces a high-contrast mode, such as Windows High Contrast Mode. Use system color keywords like CanvasText and ButtonFace to inherit the user’s chosen palette. That ensures the contrast is whatever the OS provides.

The One Misstep That Breaks Everything: Ignoring Non-Text UI

The 3:1 Rule For UI Components

A page can pass every text contrast check and still fail WCAG 2.2 because of non-text UI elements. Success Criterion 1.4.11 (Non-text Contrast) requires a 3:1 contrast ratio against adjacent colors for the visual information required to identify UI components. Borders, icons, focus indicators, and the boundaries of form fields all count. This is not optional. The common failure is a light gray border on a white background. It looks subtle and modern. It fails 3:1. The fix is to use a darker border or add a second indicator, an underline or a filled background.

Color Alone Is Not Enough

Remember WCAG 1.4.1 Use of Color. Never rely on color alone to convey information. A form field’s error state that is only a red border must also include an icon or a text label. This is a separate criterion from contrast, but it belongs in the same accessibility review. Think of a road sign that is only red text on a green background. A colorblind driver cannot read it. You need a shape or a symbol too. For a design system, your component tokens must include border colors that pass 3:1 against both the page background and the component’s fill. Test them in both light and dark modes.

When The Normal Route Is Closed: Your Fallback For Unsupported Features

color-contrast() Does Not Ship

The color-contrast() function is not shipped in any engine. No Baseline status exists because it is still a Working Draft. If you are tempted to use it, do not. The fallback is to declare a manually chosen color first, then the color-contrast() call as a progressive enhancement. Since no engine supports it, the call will be ignored. The fallback is the only thing that matters. To compute a contrast ratio at build time, use a tool like the WebAIM contrast checker or a Node.js script that implements the relative luminance formula. Do not rely on browser APIs that do not exist.

APCA And Real-World Interop

The APCA contrast method, referenced in the WCAG 3.0 Working Draft, is not integrated into any CSS specification or engine. Do not use it in CSS. For forced-colors, use the media query and system color keywords. For prefers-contrast, the media query is widely available. Use it. If a user’s device does not support prefers-contrast, they get the default styling. That is acceptable, as long as the default passes AA. The real-world interop gap for prefers-contrast is the same small fraction of older browsers. Always test on the oldest you support.

Who This Subject Suits, And Who It Does Not

The Right Reader

This subject suits the full-stack engineer who touches CSS occasionally and needs a reliable reference that tells them the modern way, the oklch() and color-mix() techniques, not the 2014 StackOverflow answer. It suits the design-system author who needs precise specification behaviour, interop quirks, and the vocabulary to defend choices to stakeholders, words like Success Criterion 1.4.3, contrast ratio, and non-text contrast. It suits the technical writer or educator who needs accurate, sourced statements about CSS features without laundering guesses into facts. You can cite the WCAG 2.2 specification and the CSS Color Module levels with confidence.

Who Should Go Elsewhere

It does not suit someone learning to code from zero. Go to web.dev/learn/css or the MDN CSS first-steps guide, then return here. It does not suit a designer who wants to learn why a layout works. That is Every Layout or Refactoring UI territory. It does not suit someone debugging a React state bug, nor someone looking for CSS-in-JS library comparisons. Those are JavaScript tooling questions, not CSS accessibility. This subject is for the person who has a contrast failure and needs the code that fixes it, with the numbers to prove it.