Home / Snippets / Color & Theming /
OKLCH accent color
Define an accent color as three custom properties — --accent-l, --accent-c, --accent-h — then derive hover and focus states with a single calc().
No credit card required. Cancel anytime.
Quick implementation
/* 1. Define accent as three channel properties */
:root {
--accent-h: 265; /* hue angle */
--accent-c: 0.22; /* chroma */
--accent-l: 0.62; /* lightness (0–1 scale) */
}
/* 2. Apply the accent */
.btn--accent {
background: oklch(var(--accent-l) var(--accent-c) var(--accent-h));
color: #fff;
}
/* 3. Derive hover — subtract lightness, keep hue & chroma */
.btn--accent:hover,
.btn--accent:focus-visible {
background: oklch(
calc(var(--accent-l) - 0.1)
var(--accent-c)
var(--accent-h)
);
}
/* 4. Ghost / outline variant */
.btn--accent-ghost {
background: transparent;
color: oklch(var(--accent-l) var(--accent-c) var(--accent-h));
border: 2px solid oklch(var(--accent-l) var(--accent-c) var(--accent-h));
}
.btn--accent-ghost:hover,
.btn--accent-ghost:focus-visible {
background: oklch(
calc(var(--accent-l) - 0.1)
var(--accent-c)
var(--accent-h)
);
color: #fff;
}
/* 5. Soft badge using alpha */
.badge--accent {
background: oklch(var(--accent-l) var(--accent-c) var(--accent-h) / 0.18);
color: oklch(calc(var(--accent-l) + 0.12) var(--accent-c) var(--accent-h));
border: 1px solid oklch(var(--accent-l) var(--accent-c) var(--accent-h) / 0.35);
}
Prompt this to your LLM
Includes role, constraints, two framework variants, and edge cases to handle.
You are a senior frontend engineer building a themeable accent color
system for a design system that targets modern browsers.
Goal: Define a single accent hue as three CSS custom properties
(--accent-h, --accent-c, --accent-l) on :root, then derive all
interactive states (hover, focus, active, disabled) using calc() on
the lightness channel — no extra color variables needed.
Technical constraints:
- Use oklch() for all accent color values — no hex, hsl, or rgba.
- Decompose the accent into --accent-h (hue, 0–360), --accent-c
(chroma, 0–0.37), and --accent-l (lightness, 0–1).
- Hover state: calc(var(--accent-l) - 0.1) — darker by 10 lightness
units, same hue and chroma.
- Focus-visible: same as hover, plus an outline using the same color.
- Active/pressed: calc(var(--accent-l) - 0.18) — slightly darker still.
- Disabled: oklch(var(--accent-l) 0 var(--accent-h)) — drop chroma to
zero (neutral gray at same lightness).
- Soft badge background: oklch(var(--accent-l) var(--accent-c)
var(--accent-h) / 0.18) with a matching semi-transparent border.
- White (#fff) is always a safe foreground when --accent-l is 0.65
or below; for lighter accents, switch to a dark foreground instead.
Framework variant (pick one):
A) Vanilla CSS — produce :root variables and utility classes
(.btn--accent, .badge--accent, .link--accent).
B) CSS Modules — export the variables from a tokens.module.css file
and consume them in component stylesheets.
Edge cases to handle:
- Some high-chroma hues clip outside sRGB on some displays; add a
@supports (color: oklch(0 0 0)) guard and an hsl() fallback for
browsers that don't support oklch().
- Changing --accent-h on a child element should cascade correctly —
verify that all derived values re-compute from the new hue.
- Document the minimum contrast ratio achieved at the default
--accent-l: 0.62 value for WCAG AA compliance.
Return CSS only (or CSS Modules if variant B is chosen).
Why oklch makes accent derivation predictable
In hsl(), adjusting the l channel by a fixed amount produces wildly different perceived brightness changes depending on the hue. Yellow at 50% lightness looks far brighter than blue at 50% lightness. This is because HSL is not perceptually uniform — it maps mathematical values to colors, not perceived ones.
oklch() uses the OKLCH color space, which is designed to be perceptually uniform. A lightness change of 0.1 looks roughly the same magnitude regardless of whether the hue is purple, green, orange, or teal. This makes calc(var(--accent-l) - 0.1) a genuinely reliable hover recipe: the darker state will always look like a credible darker version of the base color, not a muddy or washed-out variant.
Splitting the accent into --accent-h, --accent-c, and --accent-l also makes it trivial to change the brand color site-wide. Update one --accent-h value on :root and every button, link, badge, and CTA banner updates simultaneously — including their hover states — because all derived values are computed, not stored.
Lightness math for hover states
The pattern oklch(calc(var(--accent-l) - 0.1) var(--accent-c) var(--accent-h)) subtracts 0.1 from the lightness while leaving chroma and hue untouched. On a dark-mode site, making the hover state darker (lower lightness) creates depth — the element appears to recede slightly when pressed. On a light-mode site you may prefer to darken on hover too, or you could lighten the hover state for a "glow" effect by adding instead of subtracting.
A practical range for interactive states:
- Default:
--accent-l: 0.62— vivid and legible with white text - Hover:
calc(var(--accent-l) - 0.1)→0.52— clearly darker - Active/pressed:
calc(var(--accent-l) - 0.18)→0.44— deeper still - Disabled: drop chroma to
0—oklch(var(--accent-l) 0 var(--accent-h))— produces a neutral gray at the same lightness
Because the lightness scale in OKLCH runs from 0 (black) to 1 (white), these numbers are stable across hues. You can define all these offsets as additional custom properties (--accent-l-hover, etc.) or keep them as inline calc() expressions — both approaches work.
Gamut considerations
OKLCH can describe colors that exist in wide-gamut spaces (Display P3, Rec. 2020) but fall outside the sRGB gamut that most web colors are authored in. A high chroma value like --accent-c: 0.3 at a mid-range hue may be out-of-gamut on an sRGB display. Modern browsers handle this gracefully: they automatically gamut-map the color to the nearest in-gamut sRGB equivalent. The color will still render — it just won't be as vivid as on a wide-gamut screen.
If you need to guarantee sRGB-safe values, keep --accent-c at or below 0.22 for most hues. You can check a specific value in the browser DevTools color picker, which shows a gamut boundary indicator when a color clips outside sRGB.
For browsers that don't support oklch() at all (primarily older Chromium builds before 2023), provide an hsl() fallback above the oklch() declaration. Because CSS ignores unknown values, browsers without support will use the hsl() line, while modern browsers will use oklch(). Alternatively, wrap the entire system in @supports (color: oklch(0 0 0)) { … }.