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:
| Component | Symbol | Range | Meaning |
|---|---|---|---|
| Lightness | L | 0 (black) to 1 (white) | Perceived brightness |
| Chroma | C | 0 (gray) to ~`0.4` (vibrant) | Color intensity or saturation |
| Hue | H | 0 to 360 degrees | Color 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:
- 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.
- 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.
- Adjust chroma per step. Maximum chroma varies by hue and lightness. A color that is at maximum chroma at
L=0.6may be out of gamut atL=0.8. Reduce chroma at the extremes of the scale. - 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.
- Check for hardcoded hex in component code. Any
#3b82f6orrgb(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
| Component | Range | Meaning |
|---|---|---|
L | 0–1 | Lightness (perceived brightness) |
C | 0–~0.4 | Chroma (color intensity) |
H | 0–360 | Hue (angle on color wheel) |
Common Hue Angles
| Hue | Color |
|---|---|
0 | Red |
30 | Orange |
60 | Yellow |
120 | Green |
180 | Cyan |
240 | Blue |
280 | Purple |
330 | Magenta |
Theme Namespaces
| Prefix | Generates |
|---|---|
--color-* | bg-*, text-*, border-*, ring-* |
Opacity Modifiers
| Class | Output |
|---|---|
bg-brand-500/50 | oklch(... / 0.5) |
bg-brand-500/75 | oklch(... / 0.75) |
bg-brand-500/[0.37] | oklch(... / 0.37) |
Wide Gamut
| Condition | Behavior |
|---|---|
| Display P3 screen | Full chroma rendered |
| sRGB screen | Clamped to closest sRGB |
| Out of gamut | CSS 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
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Color looks different than expected | OKLCH perceptual mapping differs from sRGB | Adjust L, C, H values |
| Out of gamut | Chroma exceeds max for L/H | Reduce chroma until in gamut |
| Wide gamut not showing | Display is sRGB | Expected; renders clamped |
| Dark mode colors off | OKLCH mapping differs | Test and adjust semantic tokens |
| Gradient shows unexpected colors | Hues are circular | Consider OKLAB for linear interpolation |
| Opacity modifier fails | Color not in --color-* namespace | Verify 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
| Item | Value |
|---|---|
| Default color space | OKLCH in Tailwind v4 |
| Components | L (lightness), C (chroma), H (hue) |
| Lightness range | 0 (black) to 1 (white) |
| Chroma range | 0 (gray) to ~`0.4` (vibrant) |
| Hue range | 0 to 360 degrees |
| Syntax | oklch(0.623 0.188 250) |
| Theme directive | @theme { --color-*: oklch(...) } |
| Semantic mapping | @theme inline { --color-primary: var(--color-brand-500); } |
| Wide gamut | P3 displays render full chroma; sRGB clamps |
| Opacity modifier | bg-brand-500/50 |
| Dark mode | Variable override in [data-theme="dark"] |
Key takeaways:
- Tailwind v4 uses OKLCH for its entire palette. Every default color and every custom color defined in
@themeis 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/50compiles 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!