| |

Tailwind CSS 9 🎨 Color System, OKLCH Color Space, and Vivid Wide Gamut Palettes

Tailwind CSS v4 rebuilt its entire color system around OKLCH, a perceptually uniform color space that unlocks the wide-gamut colors modern displays can actually show. Where v3 defined colors as hex or RGB values constrained to the sRGB gamut, v4 defines every default color as an OKLCH function, and custom themes are expected to follow the same convention. The practical effect is colors that look more consistent across shades, gradients that interpolate smoothly, and access to roughly 50% more colors on Display P3 screens without any special syntax.

This chapter covers the OKLCH color model, how Tailwind exposes it through the @theme directive, the wide-gamut advantage and how it degrades gracefully on older displays, the opacity modifier behavior, and the two-tier token architecture that separates raw palette values from semantic colors.

Key point: Tailwind v4 uses OKLCH for its entire color palette. OKLCH separates lightness, chroma, and hue into perceptually uniform axes, which means changing one axis produces predictable results and colors maintain their identity across shades. On wide-gamut displays, OKLCH colors render more vividly than their sRGB equivalents; on sRGB displays, they fall back gracefully.


Why OKLCH replaced hex and RGB

The perceptual uniformity problem. HSL and RGB are not perceptually uniform. In HSL, hsl(60 100% 50%) (yellow) and hsl(240 100% 50%) (blue) have the same numerical lightness, but the yellow appears far brighter to the human eye. This makes it impossible to create a consistent tonal scale by adjusting a single lightness value. OKLCH fixes this: a color at oklch(0.7 0.15 240) and a color at oklch(0.7 0.15 30) are perceived as equally bright, differing only in hue.

The hue shift problem. In HSL, changing the lightness of a blue shifts the hue toward purple in lighter shades and toward gray in darker shades. A blue-500 and a blue-900 do not look like the same color at different brightness levels. OKLCH preserves the hue across lightness changes, so the entire scale reads as one coherent color family.

The gamut problem. sRGB covers only about 70% of the colors the human eye can perceive. Modern displays — phones, laptops, TVs — cover the Display P3 gamut, which is roughly 50% larger. Hex and RGB values are locked to sRGB, so they cannot express the vivid colors P3 displays can show. OKLCH can specify colors outside the sRGB gamut, and the browser renders them at full vividness on capable displays.

The gradient problem. sRGB gradients often produce muddy or inconsistent intermediate colors because the interpolation happens in a non-perceptual space. OKLCH gradients interpolate through lightness, chroma, and hue, producing smooth transitions that maintain vibrancy and avoid unexpected color shifts.

The accessibility problem. Because OKLCH lightness is perceptually linear, a color at L=0.5 has roughly the same perceived brightness regardless of hue. This makes it easier to build palettes where contrast ratios are consistent across color families, which is essential for WCAG-compliant design systems.


a. The OKLCH color model

OKLCH is the cylindrical representation of the OKLab color space. It has three components:

ComponentSymbolRangeMeaning
LightnessL0 (black) to 1 (white)Perceived brightness
ChromaC0 (gray) to ~`0.4` (vibrant)Color intensity or saturation
HueH0 to 360 degreesColor angle on the wheel

The syntax in CSS is oklch(L C H) or oklch(L% C H):

oklch(0.623 0.188 250)    /* a blue */
oklch(0.723 0.191 142.5)  /* a green */
oklch(0.769 0.188 70.08)  /* an amber */

Unlike HSL, where L=50% on a yellow and L=50% on a blue produce very different perceived brightness, OKLCH’s lightness axis is calibrated to human vision. Changing only the L value of a color produces a shade that looks like the same color, just lighter or darker.

Chroma controls how saturated the color is. A chroma of 0 produces a grayscale value. As chroma increases, the color becomes more vivid, up to the maximum the gamut can represent for that lightness and hue. The maximum chroma varies by hue: purple can reach higher chroma than cyan at the same lightness.

Hue is the angle on the color wheel. 0 is red, 90 is yellow-green, 180 is cyan, 270 is blue-purple, and 360 returns to red.


b. Defining custom colors with @theme

Tailwind v4 exposes the @theme directive for defining custom colors. Any variable in the --color-* namespace generates utilities for background, text, border, and other color-bearing properties.

@import "tailwindcss";

@theme {
  --color-brand-500: oklch(0.623 0.188 250);
  --color-brand-600: oklch(0.535 0.168 250);
  --color-brand-700: oklch(0.445 0.138 250);
}

This generates bg-brand-500, text-brand-600, border-brand-700, and so on. The colors are also available as CSS variables: var(--color-brand-500).

The convention is to define a full tonal scale — 50 through 950 — with consistent hue and varying lightness and chroma. A generated scale looks like this:

@theme {
  --color-brand-50: oklch(0.971 0.012 250);
  --color-brand-100: oklch(0.932 0.028 250);
  --color-brand-200: oklch(0.882 0.048 250);
  --color-brand-300: oklch(0.812 0.078 250);
  --color-brand-400: oklch(0.722 0.148 250);
  --color-brand-500: oklch(0.623 0.188 250);
  --color-brand-600: oklch(0.535 0.168 250);
  --color-brand-700: oklch(0.445 0.138 250);
  --color-brand-800: oklch(0.362 0.108 250);
  --color-brand-900: oklch(0.289 0.078 250);
  --color-brand-950: oklch(0.215 0.048 250);
}

The pattern is clear: lightness decreases monotonically from 50 to 950, chroma peaks around 500 and tapers at the extremes, and hue stays constant. This produces a scale where every step is perceptually distinct and the color identity is preserved throughout.


c. The wide-gamut advantage and graceful fallback

OKLCH colors can specify values outside the sRGB gamut. When a browser encounters an out-of-gamut color, it performs CSS gamut mapping with relative colorimetric intent, bringing the color into the display’s gamut while preserving as much of the intended appearance as possible. The color is not invalid; it is mapped.

On a Display P3 screen, an OKLCH color with chroma beyond sRGB’s maximum renders at full vividness. On an sRGB screen, the same color is clamped to the closest sRGB equivalent. The syntax does not change, and no media query is required for basic fallback.

For cases where the designer wants to explicitly provide an sRGB fallback and a P3 enhancement, the @supports or @media (color-gamut: p3) approaches work:

.accent {
  color: oklch(0.7 0.2 150);  /* sRGB-safe */
}

@media (color-gamut: p3) {
  .accent {
    color: oklch(0.7 0.3 150);  /* more vivid on P3 */
  }
}

The same technique applies to Tailwind theme values. Define the base color in @theme, then override it inside a @media (color-gamut: p3) block for capable displays.


d. Opacity modifiers and color functions

Tailwind’s opacity modifier syntax works with OKLCH colors. bg-brand-500/50 compiles to oklch(0.623 0.188 250 / 0.5), applying 50% alpha to the color.

<div class="bg-brand-500/50">50% opacity</div>
<div class="bg-brand-500/75">75% opacity</div>
<div class="bg-brand-500/[0.37]">37% opacity</div>

The modifier works with any color format Tailwind supports, but OKLCH is the recommended format because it preserves the perceptual relationships when alpha is applied.

Custom colors can also be referenced in arbitrary values or custom CSS:

<div class="bg-[oklch(0.623_0.188_250)]">Arbitrary OKLCH</div>
.custom-element {
  background: var(--color-brand-500);
  border-color: oklch(0.445 0.138 250);
}

e. Two-tier token architecture: primitives and semantics

A design system built on Tailwind v4 typically separates raw palette values from semantic colors.

Primitives are the raw OKLCH values, named by their color family and scale position: --color-blue-500, --color-gray-100. These are defined in @theme and generate the full set of color utilities.

Semantic colors reference primitives and describe intent: --color-primary, --color-surface, --color-text, --color-border, --color-danger. These are defined in @theme inline or in :root, because they map to other variables rather than literal values.

@theme {
  --color-blue-500: oklch(0.623 0.188 250);
  --color-blue-600: oklch(0.535 0.168 250);
}

@theme inline {
  --color-primary: var(--color-blue-500);
  --color-primary-hover: var(--color-blue-600);
}

Components use semantic tokens. A button uses bg-primary rather than bg-blue-500. When the brand color changes, only the semantic mapping changes; the button code is untouched. Dark mode remaps the semantics to different primitives.


f. Migrating existing themes to OKLCH

Converting an existing sRGB palette to OKLCH is a mechanical process, but it requires attention to the perceptual shifts that occur. The recommended approach:

  1. Convert all hex values to OKLCH. Use a color converter or a tool that handles the math. Preserve the intended color as closely as possible.
  2. Verify the tonal scale. Check that the lightness values decrease monotonically from light to dark. sRGB palettes often have irregular lightness steps because they were not designed in a perceptual space.
  3. Adjust chroma per step. Maximum chroma varies by hue and lightness. A color that is at maximum chroma at L=0.6 may be out of gamut at L=0.8. Reduce chroma at the extremes of the scale.
  4. Test dark mode. OKLCH colors may look slightly different in dark mode than their sRGB counterparts because the perceptual mapping is more accurate. Adjust as needed.
  5. Check for hardcoded hex in component code. Any #3b82f6 or rgb(59, 130, 246) in a component should be replaced with a theme token or an OKLCH value.

Complete Example Session

/* ============================================
PART 1: IMPORT AND BASIC THEME
============================================ */
@import "tailwindcss";

@theme {
  --color-brand-500: oklch(0.623 0.188 250);
}
/* ============================================
PART 2: FULL OKLCH TONAL SCALE
============================================ */
@theme {
  --color-brand-50: oklch(0.971 0.012 250);
  --color-brand-100: oklch(0.932 0.028 250);
  --color-brand-200: oklch(0.882 0.048 250);
  --color-brand-300: oklch(0.812 0.078 250);
  --color-brand-400: oklch(0.722 0.148 250);
  --color-brand-500: oklch(0.623 0.188 250);
  --color-brand-600: oklch(0.535 0.168 250);
  --color-brand-700: oklch(0.445 0.138 250);
  --color-brand-800: oklch(0.362 0.108 250);
  --color-brand-900: oklch(0.289 0.078 250);
  --color-brand-950: oklch(0.215 0.048 250);
}
/* ============================================
PART 3: SEMANTIC COLORS FROM PRIMITIVES
============================================ */
@theme inline {
  --color-primary: var(--color-brand-500);
  --color-primary-hover: var(--color-brand-600);
  --color-surface: var(--color-brand-50);
  --color-text: var(--color-brand-900);
}
<!-- ============================================
PART 4: USING OKLCH UTILITIES
============================================ -->
<div class="bg-brand-500 text-brand-50 p-4 rounded">
  Brand background with light text
</div>
<!-- ============================================
PART 5: OPACITY MODIFIER WITH OKLCH
============================================ -->
<div class="bg-brand-500/50">50% opacity brand</div>
<div class="bg-brand-500/75">75% opacity brand</div>
/* ============================================
PART 6: WIDE GAMUT ENHANCEMENT
============================================ */
@media (color-gamut: p3) {
  :root {
    --color-brand-500: oklch(0.623 0.25 250);
    --color-brand-600: oklch(0.535 0.22 250);
  }
}
/* ============================================
PART 7: DARK MODE WITH OKLCH SEMANTICS
============================================ */
:root {
  --color-surface: oklch(0.98 0.002 250);
  --color-text: oklch(0.15 0.006 250);
}

[data-theme="dark"] {
  --color-surface: oklch(0.12 0.006 250);
  --color-text: oklch(0.95 0.002 250);
}
<!-- ============================================
PART 8: COMPONENT WITH SEMANTIC TOKENS
============================================ -->
<article class="bg-surface text-text p-6 rounded-lg border border-brand-200">
  <h2 class="text-brand-900 font-semibold">Card Title</h2>
  <p class="text-brand-700 mt-2">Body content using semantic tokens.</p>
  <button class="mt-4 bg-primary hover:bg-primary-hover text-white px-4 py-2 rounded">
    Action
  </button>
</article>
/* ============================================
PART 9: GRADIENT WITH OKLCH
============================================ */
.gradient {
  background: linear-gradient(
    135deg,
    oklch(0.623 0.188 250),
    oklch(0.723 0.191 142.5)
  );
}
<!-- ============================================
PART 10: ARBITRARY OKLCH VALUE
============================================ -->
<div class="bg-[oklch(0.75_0.15_85)] text-black p-4 rounded">
  Custom OKLCH amber
</div>

These ten parts cover the OKLCH color model, full tonal scales, semantic mapping, opacity modifiers, wide-gamut enhancement, dark mode, component usage, gradients, and arbitrary values.


Quick Reference

OKLCH Components

ComponentRangeMeaning
L0–1Lightness (perceived brightness)
C0–~0.4Chroma (color intensity)
H0–360Hue (angle on color wheel)

Common Hue Angles

HueColor
0Red
30Orange
60Yellow
120Green
180Cyan
240Blue
280Purple
330Magenta

Theme Namespaces

PrefixGenerates
--color-*bg-*, text-*, border-*, ring-*

Opacity Modifiers

ClassOutput
bg-brand-500/50oklch(... / 0.5)
bg-brand-500/75oklch(... / 0.75)
bg-brand-500/[0.37]oklch(... / 0.37)

Wide Gamut

ConditionBehavior
Display P3 screenFull chroma rendered
sRGB screenClamped to closest sRGB
Out of gamutCSS gamut mapped

Best Practices

✅ Do This:

@theme {
  --color-brand-500: oklch(0.623 0.188 250);
}
@theme inline {
  --color-primary: var(--color-brand-500);
}
@media (color-gamut: p3) {
  :root { --color-brand-500: oklch(0.623 0.25 250); }
}

❌ Don’t Do This:

@theme {
  --color-brand-500: #3b82f6;   /* ❌ sRGB hex limits gamut */
}
@theme {
  --color-brand: oklch(0.7 0.35 150);  /* ❌ May clip; verify chroma */
}

Common Pitfalls

PitfallWhy It HappensFix
Color looks different than expectedOKLCH perceptual mapping differs from sRGBAdjust L, C, H values
Out of gamutChroma exceeds max for L/HReduce chroma until in gamut
Wide gamut not showingDisplay is sRGBExpected; renders clamped
Dark mode colors offOKLCH mapping differsTest and adjust semantic tokens
Gradient shows unexpected colorsHues are circularConsider OKLAB for linear interpolation
Opacity modifier failsColor not in --color-* namespaceVerify theme registration

Real-World Examples

1. Brand Blue Scale

@theme {
  --color-brand-500: oklch(0.623 0.188 250);
  --color-brand-600: oklch(0.535 0.168 250);
  --color-brand-700: oklch(0.445 0.138 250);
}

2. Semantic Primary

@theme inline {
  --color-primary: var(--color-brand-500);
}

3. Success Green

@theme {
  --color-success-500: oklch(0.723 0.191 142.5);
}

4. Warning Amber

@theme {
  --color-warning-500: oklch(0.769 0.188 70.08);
}

5. Danger Red

@theme {
  --color-danger-500: oklch(0.6 0.2 25);
}

6. Surface and Text

@theme inline {
  --color-surface: oklch(0.98 0.002 250);
  --color-text: oklch(0.15 0.006 250);
}

7. Dark Mode Override

[data-theme="dark"] {
  --color-surface: oklch(0.12 0.006 250);
  --color-text: oklch(0.95 0.002 250);
}

8. P3 Enhancement

@media (color-gamut: p3) {
  :root { --color-brand-500: oklch(0.623 0.25 250); }
}

9. Opacity Usage

<div class="bg-brand-500/50">Overlay</div>

10. Arbitrary OKLCH

<div class="bg-[oklch(0.75_0.15_85)]">Amber</div>

Visual

OKLCH vs HSL: Perceptual Uniformity

┌──────────────────────────────────────────────────────────────┐
│  HSL: SAME L VALUE, DIFFERENT PERCEIVED BRIGHTNESS           │
│                                                              │
│  hsl(60 100% 50%)  ██████████  yellow (appears very bright) │
│  hsl(240 100% 50%) ██████████  blue   (appears much darker)  │
│                                                              │
│  Same numerical lightness, different perceived brightness.   │
│                                                              │
│  OKLCH: SAME L VALUE, SAME PERCEIVED BRIGHTNESS              │
│                                                              │
│  oklch(0.7 0.15 60)   ██████████  yellow (equally bright)    │
│  oklch(0.7 0.15 240)  ██████████  blue   (equally bright)    │
│                                                              │
│  Lightness is calibrated to human vision.                    │
└──────────────────────────────────────────────────────────────┘

OKLCH Color Space

┌──────────────────────────────────────────────────────────────┐
│  OKLCH CYLINDRICAL MODEL                                     │
│                                                              │
│        L (Lightness)                                         │
│        ▲                                                     │
│        │   0.0 = black                                       │
│        │   1.0 = white                                       │
│        │                                                     │
│        │    C (Chroma)                                       │
│        │    ◄──────►                                         │
│        │   0 = gray   0.4 = vibrant                          │
│        │                                                     │
│        └──────────────► H (Hue)                              │
│                        0–360 degrees                         │
│                                                              │
│  oklch(0.623 0.188 250)                                      │
│  └── L=0.623, C=0.188, H=250 (blue)                          │
└──────────────────────────────────────────────────────────────┘

Wide Gamut Rendering

┌──────────────────────────────────────────────────────────────┐
│  SAME OKLCH COLOR, DIFFERENT DISPLAYS                        │
│                                                              │
│  oklch(0.7 0.3 150)                                          │
│                                                              │
│  Display P3 screen:                                          │
│  ┌────────────────────────────────────────────────────────┐  │
│  │  Full vivid green rendered                             │  │
│  └────────────────────────────────────────────────────────┘  │
│                                                              │
│  sRGB screen:                                                │
│  ┌────────────────────────────────────────────────────────┐  │
│  │  Gamut mapped to closest sRGB green                    │  │
│  │  (slightly less vibrant, same hue)                     │  │
│  └────────────────────────────────────────────────────────┘  │
│                                                              │
│  The syntax does not change. The browser handles mapping.    │
└──────────────────────────────────────────────────────────────┘

Two-Tier Token Architecture

┌──────────────────────────────────────────────────────────────┐
│  PRIMITIVES (@theme)          SEMANTICS (@theme inline)      │
│                                                              │
│  --color-brand-50             --color-primary                │
│  --color-brand-500  ────────► --color-primary-hover          │
│  --color-brand-900            --color-surface                │
│                               --color-text                   │
│                                                              │
│  Raw values                    Intent-based names            │
│  Generate utilities            Map to primitives             │
│  Never used in components      Used in components            │
│                                                              │
│  Component: bg-primary                                       │
│  └── resolves to var(--color-brand-500)                      │
│      └── oklch(0.623 0.188 250)                              │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
Default color spaceOKLCH in Tailwind v4
ComponentsL (lightness), C (chroma), H (hue)
Lightness range0 (black) to 1 (white)
Chroma range0 (gray) to ~`0.4` (vibrant)
Hue range0 to 360 degrees
Syntaxoklch(0.623 0.188 250)
Theme directive@theme { --color-*: oklch(...) }
Semantic mapping@theme inline { --color-primary: var(--color-brand-500); }
Wide gamutP3 displays render full chroma; sRGB clamps
Opacity modifierbg-brand-500/50
Dark modeVariable override in [data-theme="dark"]

Key takeaways:

  • Tailwind v4 uses OKLCH for its entire palette. Every default color and every custom color defined in @theme is an OKLCH value, replacing the sRGB hex and RGB values of v3.
  • OKLCH is perceptually uniform. Changing the lightness of a color preserves its hue and perceived brightness, unlike HSL where the same numeric change produces inconsistent results.
  • OKLCH unlocks wide-gamut colors. On Display P3 screens, OKLCH colors render more vividly than sRGB equivalents. On sRGB screens, they fall back gracefully with CSS gamut mapping.
  • Define a full tonal scale. A complete palette from 50 to 950 keeps hue constant while varying lightness monotonically and chroma subtly.
  • Separate primitives from semantics. Raw OKLCH values generate utilities; semantic tokens reference them and describe intent. Components use semantics, so theme changes require only remapping.
  • Opacity modifiers work with OKLCH. bg-brand-500/50 compiles to an OKLCH color with alpha, preserving perceptual relationships.
  • Test dark mode and wide-gamut carefully. OKLCH colors may look slightly different than expected because the mapping is perceptually accurate. Verify contrast and adjust as needed.

Remember: Tailwind v4’s color system is built on OKLCH, and that choice is not cosmetic. It is the technical foundation that makes perceptual consistency, wide-gamut vibrancy, and smooth gradients possible. The @theme directive is where colors are defined, and the --color-* namespace is what generates utilities. A well-designed palette defines a full tonal scale in OKLCH, maps semantic tokens to those primitives, and uses the semantics in components so that theming and dark mode are variable overrides rather than duplicated classes. The result is a color system that looks consistent across shades, renders beautifully on modern displays, and degrades gracefully on older ones.



Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!