Guides & How-Tos

Building Accessible, Contrast-Safe Color Systems in CSS Using OKLCH and WCAG

19 min read
Contrast-safe OKLCH color systems

You picked a beautiful brand color, generated a shade scale in HSL, and shipped it, only to discover that the mid tones look muddy on wide-gamut screens, the lightness steps feel unevenly spaced, and an accessibility audit flags half your text pairings as WCAG failures. The root cause is that HSL is not perceptually uniform, so equal numeric steps do not produce equal perceived differences, and most palette generators run in the cloud, uploading your unreleased brand palette to a server you do not control. That combination of visual inconsistency and data exposure is the exact problem a local, perceptually grounded workflow eliminates.1

This post walks through building a complete, contrast-safe color system entirely in the browser using the client-side color conversion tools on CapyToolkit, specifically the Color Format Converter. You will learn to pick a base color, convert it to OKLCH, generate a perceptually uniform shade scale, check every text pairing against WCAG 2.1 AA and AAA, and export the result as CSS custom properties and Tailwind v4 tokens. Nothing is uploaded at any stage, so your design tokens and brand coordinates stay on your machine from the first conversion to the final stylesheet.

Why HSL-Based Color Scales Break on Real Screens

Rather than modeling human brightness perception, HSL lightness acts as a basic mathematical interpolation between black and white through a single hue. Consequently, a vivid yellow and a deep blue set to the exact same 50% lightness value will look radically different in physical brightness to your users. Far from a rendering bug, this mismatch represents a structural limitation of the HSL model, and it shows up the moment you build a multi-hue design system.

The consequence for design systems is that mid-range HSL steps compress or stretch unpredictably, so your “500” step is not perceptually halfway between “100” and “900”, and brand hues with different chroma land at different perceived brightnesses at the same lightness value. When you try to align text contrast across a full palette built in HSL, you end up with some stops that read comfortably and others that strain the eye, because the spacing between stops does not match how people actually perceive lightness. You can read more about these structural limitations on the HSL and HSV disadvantages page.

Why this matters for accessibility is that the WCAG algorithm computes contrast directly from relative luminance, not HSL lightness, so a scale that looks evenly spaced to your eye can still produce contrast failures at specific steps; you cannot eyeball HSL and assume WCAG compliance.2 A stop that feels like it sits midway between your lightest and darkest values might fall just below the 4.5:1 threshold for normal text, and you will not catch that failure until an audit or a user complaint forces the issue. The only reliable way to build a contrast-safe scale is to work in a color space where numeric steps map to perceived steps, then verify each pairing against the actual WCAG formula.

Why OKLCH Produces More Reliable Scales Than HSL

OKLCH stands for Lightness, Chroma, and Hue in the OKLab color space, developed by Björn Ottosson, and its defining property is perceptual uniformity, meaning equal numeric steps in any direction produce roughly equal perceived color differences.3 That single property is what makes OKLCH suitable for building shade scales, adjusting lightness without shifting the perceived hue, and generating color systems that behave predictably across the full range of stops. The OKLab design and its cylindrical OKLCH form are documented in detail on the Oklab color space Wikipedia entry, and you can convert colors between HEX, RGB, HSL, and OKLCH in your browser to follow along with every example in this post.

OKLCH is part of the CSS Color Module Level 4 specification and supported natively in all modern browsers, so you can write color: oklch(0.65 0.18 30) directly in production CSS without a preprocessor or a build step.4 Chrome and Firefox have supported oklch() since early 2023, and Safari since version 15.4 in March 2022, which means OKLCH is no longer experimental; it is a production-ready color syntax you can ship today. The MDN oklch() reference documents the syntax, browser compatibility, and practical usage patterns.

The practical payoff for scale-building is that when you step lightness in fixed increments in OKLCH, each step feels equally spaced to the human eye, which is exactly what a well-behaved design-token shade scale requires. You no longer need to hand-tune every stop to compensate for HSL’s perceptual unevenness, and you can generate a full monochromatic scale by fixing chroma and hue while stepping lightness in even increments. That predictability is what lets you automate scale generation and trust the output without visually inspecting every single stop.

How OKLCH Coordinates Map to Design Decisions

Lightness (L, 0 to 1) controls perceived brightness and is the primary axis for building shade scales, so this is the channel you step to generate the “100, 200, 900” tokens that form the backbone of a design system. A typical shade scale runs from roughly 0.95 at the lightest stop down to about 0.2 at the darkest, with the brand’s base color usually landing near 0.5 to 0.6 depending on how vivid the original hue is.5 Keeping chroma and hue fixed while stepping lightness produces a clean monochromatic scale that reads as a single color family.

Chroma (C, 0 to roughly 0.4 and beyond) controls color intensity, and hue (H, 0 to 360) selects the color family, so once you fix those two values you can generate an entire scale by varying only lightness.5 That separation of concerns is what makes OKLCH so practical for token systems: the hue and chroma define the brand identity, while the lightness steps define the functional roles like background, surface, border, and text. When you need to adjust the brand color later, you change one chroma or hue value and regenerate the scale, rather than editing every stop by hand.

When to Drop Chroma at the Extremes

At very low and very high lightness values, high chroma produces colors that fall outside the sRGB gamut and get clipped, which shifts hue in ways that break the visual consistency of your scale. A saturated blue at lightness 0.95, for example, cannot be represented accurately in sRGB, so the browser clamps the per-channel RGB values and the resulting color drifts toward purple or cyan.6 The fix is to reduce chroma near the extremes, tapering it down for the lightest and darkest stops while keeping full chroma through the mid-range where the gamut can hold it.

CapyToolkit’s converter shows the full set of converted values side by side, so you can see the small rounding that occurs when an OKLCH value passes through the pipeline and is quantized back to integer 0 to 255 RGB at the end. The Evil Martians team documented this problem thoroughly in their OKLCH in CSS chronicle, including the hue shifts that current browsers introduce when they clamp per-channel RGB instead of reducing chroma as the CSS Color 4 spec recommends, which in the canonical test case reaches roughly 22 degrees.7 Until browsers catch up with the spec, convert your chosen OKLCH value to HEX, then back to OKLCH, and reduce chroma if the hue angle shifts by more than a couple of degrees.

Picking a Base Color and Converting It to OKLCH

Start from whatever you have, whether that is a HEX code from a brand guide, an RGB value pulled from a screenshot, or a named CSS color, and type it into any field of CapyToolkit’s converter to get the OKLCH equivalent instantly. The converter supports HEX, RGB, HSL, HSV, OKLCH, and all 148 CSS named colors, and every field updates the others in real time, so you can work in whatever format your source material uses without reformatting by hand.8 If your designer handed you a Figma HEX string, paste it into the HEX field and read the OKLCH value directly.

The CSS Named field finds the closest named color using perceptual OKLab distance rather than raw RGB distance, so a muted red correctly matches firebrick instead of a numerically close but visually wrong alternative. That distinction matters because raw RGB distance treats color as a three-dimensional coordinate space, which does not match human perception; two colors can be close in RGB values while looking nothing alike. By measuring distance in OKLab space, the converter finds the named color that actually looks most like your pick, and the small swatch beside the field lets you judge the match at a glance.

Once you have the OKLCH coordinates for your base color, record the lightness, chroma, and hue values, because those three numbers are the seed from which your entire shade scale will grow. You can use the HEX to OKLCH conversion view to go from a brand HEX straight to OKLCH coordinates, or the general color code converter if your source is in RGB or HSL instead. The CSS color converter is the right starting point when you are working from an existing CSS color value and want to move it into OKLCH for scale generation.

Building a Perceptually Uniform Scale in OKLCH

Decide on your scale stops first, and a common set is 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950, with 500 typically matching your base brand color. That eleven-stop scale gives you enough granularity for backgrounds, surfaces, borders, and text without creating so many tokens that your system becomes unwieldy. Some teams use a smaller seven-stop scale for simpler products, but the eleven-stop set has become a de facto standard because it maps cleanly onto Tailwind’s default palette structure.9

Fix chroma and hue from your base color, then step lightness in roughly even increments from about 0.95 at the lightest stop down to about 0.2 at the darkest, copying the OKLCH value at each stop. Because OKLCH is perceptually uniform, these even lightness steps produce a scale where each stop feels roughly equidistant from its neighbors, which is the property HSL cannot guarantee and the reason HSL-based scales often feel lopsided. You can generate the full set of OKLCH coordinates in a few minutes by typing each lightness value into the converter and recording the result.

Verify each stop by pasting it back into the converter and confirming the HEX and RGB values look correct on your target display, because round-trip conversion through floating-point intermediates can shift values by a perceptible amount only at extreme chroma. The pipeline rounds to integer 0 to 255 RGB at the end, and that rounding is inherent to any fixed-precision color representation, but it is usually below the threshold of human perception. If a stop looks off after the round trip, nudge the lightness by 0.01 or 0.02 and re-check until the value stabilizes.

  1. Convert your base brand color to OKLCH. Record the exact Lightness, Chroma, and Hue coordinates as your design seed.
  2. Choose your scale stops, typically 50 through 950, and assign lightness values that step evenly from light to dark.
  3. Fix chroma and hue at your base values, then compute each stop by varying only lightness.
  4. Paste each OKLCH stop back into the converter and verify the HEX and RGB output looks correct on your target display, because round-trip quantization can nudge a value at extreme chroma.
  5. Run the contrast check at each stop to determine whether black or white text passes WCAG AA.
  6. Taper your chroma values at both extremes. This simple adjustment prevents colors from breaking the sRGB boundaries and clipping.

Handling Gamut Clipping at High Chroma

Saturated brand colors, especially in the blue and red range, can exceed the sRGB gamut at mid-to-high chroma, and current Chrome and Safari clamp per-channel RGB, which shifts both hue and lightness in ways that break your scale’s consistency. The CSS Color 4 spec recommends chroma reduction to preserve hue, but most browsers have not yet implemented that method, so the practical impact is that a carefully chosen OKLCH value can render as a visibly different color once it passes through the sRGB clamp. The CSS Color Level 4 guide explains the spec’s recommended approach and the current browser behavior.

The practical test is to convert your chosen OKLCH value to HEX, then convert that HEX back to OKLCH, and if the hue angle shifts by more than a couple of degrees, reduce chroma until the round-trip is stable. This extra step takes seconds per stop and saves you from shipping a scale that looks correct in your design tool but drifts on real screens. For most brand colors with moderate chroma, the issue only appears at the lightest and darkest stops, which is why the next section covers tapering chroma at the extremes. Taking this preventative step preserves the visual relationship of your scale across standard, wide-gamut, and low-end displays alike.

Adjusting Chroma Per Stop for a Natural-Looking Scale

Real-world design systems rarely keep chroma flat across the mid-range; instead they use a chroma hump that peaks around stops 400 through 600 and tapers toward both ends. This avoids neon-looking highlights at the top and muddy shadows at the bottom, and it works with the gamut rather than fighting it, since sRGB can hold more chroma at mid-lightness than at the extremes. Shape this curve by eye in the converter, reducing chroma for stops 50 through 200 and 800 through 950 while letting it rise through the middle. The goal is a scale that feels like a single, coherent color family from the lightest background to the darkest text, without any stop jumping out as obviously more vivid or more grey than its neighbors.

Reading the WCAG Contrast Checker and Picking Text Colors

WCAG 2.1 defines your contrast ratio with the formula (L1 + 0.05) / (L2 + 0.05), where L1 and L2 are the relative luminance values of the lighter and darker colors respectively, producing a ratio from 1:1 for identical colors up to 21:1 for black on white. Relative luminance itself is the weighted sum L = 0.2126·R + 0.7152·G + 0.0722·B computed from linearized sRGB channel values, and the contrast ratio formula consumes those two luminance values to produce the final metric.10 Both definitions live in the W3C WCAG 2.1 specification. Understanding this two-step pipeline matters because it explains why two colors that look similarly bright can still produce a low contrast ratio, and why small changes in the darker color have an outsized effect.

The WCAG 2.1 thresholds are: AA requires 4.5:1 for normal text and 3:1 for large text, which the spec defines as 18pt or 14pt bold; AAA requires 7:1 for normal text and 4.5:1 for large text; UI components and graphical objects require 3:1 at AA.10 These thresholds are not arbitrary; they correspond to specific levels of visual acuity and viewing conditions, and they form the legal baseline for accessibility in many jurisdictions. To keep these accessibility requirements top of mind during palette construction, you can verify your coordinates against these official WCAG 2.1 threshold levels:

ThresholdNormal textLarge text (18pt+ or 14pt+ bold)UI components / graphics
WCAG 2.1 AA4.5:13:13:1
WCAG 2.1 AAA7:14.5:1not defined

CapyToolkit’s built-in contrast check shows the ratio for both black text and white text on your selected color, so you can immediately see which pairing reads better and whether it clears AA or AAA. For each background stop in your scale, record which text color, black or white, passes AA, and that recording becomes your token mapping: for example, text on stops 100 through 500 uses black, while text on stops 600 through 950 uses white. The WCAG color contrast checker gives you the same ratio readouts in a dedicated view that is useful when you are evaluating a single background color against multiple foreground options.

Exporting OKLCH Tokens to CSS and Tailwind v4

Define your scale as CSS custom properties on :root, one variable per stop, using the OKLCH values directly, because this keeps the source of truth in the perceptually uniform space and lets the browser handle rendering without any build-time color manipulation. A token-naming convention like --color-brand-500, combining a semantic role with a numeric stop, separates the design intent from the underlying value, so you can re-tune the scale later without renaming every component that references it. That separation is what makes a token system maintainable across a large codebase and a long product lifecycle.

Tailwind CSS v4 uses OKLCH natively for its built-in palette, so you can drop your brand OKLCH coordinates directly into a Tailwind v4 theme without converting to RGB or HEX first.9 The framework’s color system expects OKLCH values for its default grays and accent colors, which means your custom brand tokens will live in the same color space as the framework’s own palette and behave consistently across utilities. The Tailwind v4 color converter returns the exact OKLCH coordinate for any brand color, formatted for direct use in a Tailwind v4 theme configuration.

Because the entire workflow, from conversion through contrast checking to token export, runs in CapyToolkit’s client-side converter, your brand palette never leaves your browser, which matters when you are working with unreleased product colors or a client’s confidential design system. You get the same output you would from a cloud-based generator, but the data stays local from the moment you paste the first HEX value to the moment you copy the final token list. The CSS custom properties guide walks through the theming patterns that make this export step clean and maintainable.

A Minimal OKLCH Design-Token Block

The CSS block below defines a five-stop sample brand scale in OKLCH, accompanied by semantic text tokens mapped to ensure contrast-safe pairings on light and dark surfaces. Each --color-brand-* variable holds the OKLCH coordinates for one stop, and each --color-text-* variable references the stop that passes WCAG AA against that surface, based on the contrast check you performed for your palette.

:root {
  --color-brand-100: oklch(0.93 0.05 30);
  --color-brand-300: oklch(0.78 0.13 30);
  --color-brand-500: oklch(0.62 0.18 30);
  --color-brand-700: oklch(0.46 0.16 30);
  --color-brand-900: oklch(0.30 0.10 30);
  --color-text-on-light: var(--color-brand-900);
  --color-text-on-dark: var(--color-brand-100);
}

Demonstrate a dark-mode override by redefining the same custom properties under @media (prefers-color-scheme: dark) or a .dark class, swapping foreground and background roles so that surfaces become dark and text becomes light. The OKLCH values themselves do not change; only the mapping between stops and semantic roles flips, which keeps your dark-mode implementation simple and your token definitions authoritative.

Keeping Figma and CSS in Sync Without Uploading Palettes

While keeping your code system accessible is a technical requirement, maintaining parity between those production variables and your vector canvases requires a coordinated design handoff. Figma lets you view and export colors as HEX or HSL, but stores them internally as floating-point linear-light RGB, so the CSS handoff usually involves copying values manually or using a plugin that may send your palette to a third-party server.11 That handoff is a common source of both data exposure and version drift, since the Figma file and the CSS codebase fall out of sync whenever updates happen in only one place. A local conversion workflow removes both the privacy risk and the synchronization problem.

With CapyToolkit’s converter you can take a Figma HEX value, convert it to OKLCH locally, build your scale, and paste the final tokens back into Figma as HEX, so the round-trip is quick and nothing is uploaded at any point. The Figma to CSS color workflow guide walks through the exact steps for moving colors between Figma and CSS without a cloud intermediary. This keeps a single source of truth: the OKLCH values in your CSS are the canonical palette, and Figma is a consumer that gets updated values, not the other way around.

Troubleshooting Common Pitfalls When Going Client-Side

Round-trip conversion drift is the most common surprise when working with OKLCH in the browser, because the values can shift slightly after converting to RGB and back, as the pipeline rounds to integer 0 to 255 RGB at the end of the process. The difference is below human perception for most colors, but it can confuse exact-match checks if you are comparing a converted value against an original by string equality rather than by visual inspection. Treat the round-trip shift as expected behavior, not a bug, and verify your stops by eye rather than by exact numeric comparison.

Wide-gamut displays introduce another subtlety: OKLCH values that land in the Display P3 gamut may render differently on standard sRGB screens, so you should test your final HEX values on a standard sRGB monitor to confirm they look right for the majority of your users. A color that looks vivid and well-balanced on a wide-gamut MacBook Pro can appear slightly duller or shifted on a typical office monitor, and that difference is a property of the display hardware, not your palette. Designing for sRGB first and treating wide-gamut as a progressive enhancement is the safest approach for now.

Offline use is one of the practical advantages of a client-side workflow: CapyToolkit’s converter works entirely offline once the page loads, so you can build and verify palettes on a plane, in a coffee shop with unreliable Wi-Fi, or on an air-gapped machine with no network exposure at all. That capability matters for teams working on confidential products where even the fact that a palette exists is sensitive information. Your tool inputs never leave your browser; the converting happens locally, so the actual color values you type are not uploaded, logged, or sent in any request. Site-level analytics cookies may still fire on page load, but they carry no information about your design work, only that the page loaded.

Sources
  1. 1.

    CSS-Tricks, “oklch(),” css-tricks.com, May 2025. https://css-tricks.com/almanac/functions/o/oklch/

  2. 2.

    Mozilla Developer Network, “Web Accessibility: Understanding Colors and Luminance,” developer.mozilla.org, December 2025. https://developer.mozilla.org/en-US/docs/Web/Accessibility/Guides/Colors_and_Luminance

  3. 3.

    Björn Ottosson, “A perceptual color space for image processing,” bottosson.github.io, December 2020. https://bottosson.github.io/posts/oklab/

  4. 4.

    W3C, “CSS Color Module Level 4,” w3.org, June 2026. https://www.w3.org/TR/css-color-4/

  5. 5.

    W3C, “CSS Color Module Level 5,” drafts.csswg.org, accessed June 2026. https://drafts.csswg.org/css-color-5/

  6. 6.

    Miriam Suzanne, “[css-color-4] Channel clipping breaks author expectations, especially when using ‘perceptually uniform’ spaces,” GitHub, 2021. https://github.com/w3c/csswg-drafts/issues/9449

  7. 7.

    Adam Argyle, “Migrate to HD CSS color,” developer.chrome.google.cn, February 2023. https://developer.chrome.com/docs/css-ui/migrate-hd-color

  8. 8.

    Mozilla Developer Network, ” CSS type,” developer.mozilla.org, accessed June 2026. https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/named-color

  9. 9.

    Tailwind CSS, “Colors,” tailwindcss.com, accessed June 2026. https://tailwindcss.com/docs/colors

  10. 10.

    WebAIM, “Contrast and Color Accessibility,” webaim.org, accessed June 2026. https://webaim.org/articles/contrast/

  11. 11.

    Figma, “RGB/RGBA,” developers.figma.com, accessed June 2026. https://developers.figma.com/docs/plugins/api/RGB/

More in Guides & How-Tos