Home / Snippets / Color & Theming /
OKLCH text color
Use oklch() to set text colors that maintain consistent perceived brightness across every hue — unlike hsl() where yellow appears far brighter than blue at the same lightness value.
oklch — uniform perceived brightness (L=0.72)
hsl — uneven perceived brightness (S=70%, L=60%)
Text hierarchy with oklch custom properties
Quick implementation
:root {
/* Text color hierarchy — oklch keeps lightness perceptually uniform */
--text-primary: oklch(0.93 0.01 260); /* near-white, high-contrast heading */
--text-body: oklch(0.78 0.01 260); /* comfortable body copy */
--text-muted: oklch(0.60 0.02 260); /* secondary labels, metadata */
--text-accent: oklch(0.72 0.19 265); /* brand-colored links and CTAs */
}
body { color: var(--text-body); }
h1, h2, h3 { color: var(--text-primary); }
.text-muted { color: var(--text-muted); }
a, .text-link { color: var(--text-accent); }
Prompt this to your LLM
Covers role, constraints, color system design, framework variants, and contrast checking.
You are a senior frontend engineer designing a typographic color system.
Goal: Build a text color hierarchy using oklch() that maintains perceptually
uniform brightness across all hues — so heading, body, muted, and accent
text each sit at a distinct, consistent visual weight.
Technical constraints:
- Use oklch(L C H) exclusively — no hex, hsl, or rgb values.
- Define all text colors as CSS custom properties on :root so they are
themeable and reusable across components.
- The L (lightness) channel controls perceived brightness uniformly across
hues; adjusting only L is enough to create a clear visual hierarchy.
- Keep chroma (C) low for neutral text (0.01–0.03) and higher for accent
text (0.15–0.22) to add brand color without compromising readability.
- Hue (H) should stay consistent across all neutral levels for a cohesive,
slightly tinted neutral palette.
Color hierarchy to produce:
1. --text-primary — highest contrast, used for headings (L ≈ 0.93)
2. --text-body — comfortable reading weight, used for paragraphs (L ≈ 0.78)
3. --text-muted — secondary info, labels, metadata (L ≈ 0.60)
4. --text-accent — brand color for links and CTAs, higher chroma (L ≈ 0.72, C ≈ 0.19)
Framework variant (pick one):
A) Plain CSS — custom properties on :root, applied via descendant selectors.
B) CSS Modules — export each token as a local variable and apply per component.
Edge cases to handle:
- Verify that --text-primary on a dark background (oklch ≈ 0.13) passes
WCAG AA contrast (4.5:1 for body text, 3:1 for large text).
- If the accent hue falls in the yellow range (H 80–105), perceived contrast
against a dark background may be higher than intended — lower L slightly.
- Provide a prefers-color-scheme: light block that inverts the lightness
scale so the same custom property names work in both themes.
Return only CSS (or CSS Modules if variant B is chosen).
Why oklch lightness maps to perceived brightness
The human visual system does not perceive brightness linearly. Yellow light triggers multiple cone types simultaneously and registers as exceptionally bright; blue light stimulates fewer cones and appears darker at the same physical energy. hsl() was designed for screens, not human perception — its lightness channel is a geometric average of the RGB components, which means hsl(60 70% 60%) (yellow) and hsl(225 70% 60%) (blue) have the same mathematical lightness but wildly different perceived brightness.
OKLCH is built on the OKLab color space, which was engineered specifically to match human vision data. Its L channel is calibrated so that equal steps in L produce equal steps in perceived brightness — at any hue. Change only the H value and the lightness your eye registers stays the same. This makes oklch() the right tool any time you want consistent visual weight across a multi-hue palette.
Building a text color hierarchy
A well-designed text hierarchy needs at least four levels: primary (headings), body (paragraphs), muted (labels, metadata), and accent (links, calls to action). With oklch(), each level is a single L adjustment:
- Primary text —
oklch(0.93 0.01 260). Near-white with a faint cool tint. Reserved for headings where maximum contrast is needed. - Body text —
oklch(0.78 0.01 260). Slightly reduced lightness reduces strain on long reads while staying well above WCAG AA contrast on a dark background. - Muted text —
oklch(0.60 0.02 260). Secondary information that should recede visually. Meets AA contrast for large text (18px+); use sparingly for body-size copy. - Accent text —
oklch(0.72 0.19 265). Higher chroma introduces brand color. Lightness is dialed to maintain readability; avoid going below L 0.65 for links on dark backgrounds.
Keeping all neutrals at the same hue angle (260 in this example) gives the palette a coherent slightly-blue-tinted-white feel rather than flat gray. Swap the hue to 30 for a warm palette or 180 for a teal-tinted one — the perceived brightness relationships stay intact.
Contrast on dark backgrounds
All four levels above were chosen against the site's background of oklch(0.13 0.02 260). Because oklch lightness correlates with perceived brightness, you can estimate contrast ratios by checking the L gap: primary text at L 0.93 against a background at L 0.13 is a very high-contrast pairing. Muted text at L 0.60 against L 0.13 is still readable but narrower — confirm with a contrast checker before using at small sizes.
For light-mode themes, flip the L values: backgrounds near L 0.98, primary text near L 0.10, body near L 0.25, muted near L 0.45. The same custom property names work in both modes — wrap the overrides in @media (prefers-color-scheme: light) or a [data-theme="light"] attribute selector and the components never need to change.
One gotcha: very high chroma yellows (H 80–105) can appear brighter than the L value suggests because they stimulate the luminance channel of human vision more than other hues. If your accent hue sits in that range, nudge L down by 0.05–0.08 and re-check contrast.