Addon · Colors
One hue. Sixteen shades.
OKLCH-powered colors with automatic opacity, brightness, and darkness variants. Drag the logo balls to remix the brand hue.
Live HUE
Three numbers — three hues — generate the full system below.
All swatches
5 base colors × 8 variants. Same naming pattern, every time.
_c-brand /* color */ _bg-brand /* background */ _bc-brand /* border-color */
brand /* full */ brand_ /* 65% */ brand__ /* 35% */ brand___ /* 15% */
brand-d /* darker */ brand-b /* brighter */ brand-d_ /* deeper dark */ brand-b_ /* lighter bright */
Dark / Light
One pivot, two variables. The same --dark and --light tokens flip their lightness automatically — components stay mode-agnostic. OKLCH keeps chroma and hue intact; only the L channel swaps.
Two CSS variables drive the entire mode swap. Everything else reads from them.
/* Light mode (default) */ --current-lightness: 96.4%; --current-darkness: 6.4%; /* Dark mode — when .toggle-theme is on */ --current-lightness: 5%; --current-darkness: 95%;
--dark and --light reference the pivot. Swap the pivot, every consumer flips at once — same OKLCH chroma and hue, swapped lightness. These are the :root defaults; a theme overrides the chroma and hue.
--dark-lightness: var(--current-darkness); --dark-chroma: 0.05; --dark-hue: var(--brand-hue); --light-lightness: var(--current-lightness); --light-chroma: 0.1; --light-hue: var(--brand-hue);
Same markup, same tokens. Only --current-lightness and --current-darkness change between cards.
Add the toggle-theme class on the body. EVA also flips the brightness offsets so the -d modifier still darkens and -b still brightens visually.
<body class="current-theme theme-eva toggle-theme">
Pass $auto-theme-switch: true to the SCSS config. EVA generates a prefers-color-scheme: dark media query that swaps the pivot for you — no JS, no class.
@use 'eva-css-fluid' with ( $auto-theme-switch: true );
Write _c-dark _bg-light and forget about modes. The pivot does the work — your component code stays identical in both states.
Try it — hit the sun icon at the top-right of this page. Every swatch above flips at once.
New in 2.4
Per-role brightness
The four brightness steps used to be four global offsets shared by all five bases. Every base can now override its own — and opt into a proportional step that never clips.
OKLCH lightness is clamped to 0–100%. With --light-lightness at 96.4%, both bright steps land on the exact same white.
--light-b = 96.4% + 10% = 106.4% → 100% --light-b_ = 96.4% + 30% = 126.4% → 100%
It is symmetric: in dark mode --dark-d and --dark-d_ collapse together on black. On every neutral, in every mode, 2 of the 4 steps were unusable — precisely on dark and light, the two most used bases.
Each step reads its own role token first and falls back to the global one. The proportional term is opt-in: the ratio defaults to 0, so its term cancels out and the absolute behaviour is untouched.
lightness = base
+ absolute offset
+ (bound − base) × ratio
The bound is the limit a step pushes towards — 100% for a brightening step, 0% for a darkening one. It flips with the mode, like the offsets.
Same base, same two steps. On the left the default absolute offsets, on the right the same steps switched to proportional. Toggle the theme — it holds in both modes.
Both bright steps saturate. One color, printed twice.
Each step takes a share of the remaining headroom. Always distinct, always in gamut.
Each swatch prints the OKLCH lightness actually painted, read back from the browser. The right-hand card carries four inline custom properties — nothing else differs, same markup, same classes.
--light-brighter: 0%; --light-brighter-ratio: .35; --light-brighter_: 0%; --light-brighter_-ratio: .7;
<base> is brand, accent, extra, dark or light. <token> is darker, brighter, darker_ or brighter_.
--<base>-<token> /* per-role absolute offset */ --<base>-<token>-ratio /* per-role proportional part */ --<base>-<token>-bound /* per-role target bound */ --<token>-ratio /* global, defaults to 0 */ --<token>-bound /* global, follows the mode */
The most common case: neutrals want 2 to 4 points to stay readable, an accent wants 12 to 30 to mark its states.
.current-theme {
--dark-darker: -2%;
--dark-brighter: 4%;
--accent-brighter_: 12%;
}
Only the named step moves. --dark-b_ keeps using the global --brighter_.
Set the absolute part to 0 to go fully proportional. Both steps stay distinct and inside the gamut, in both modes.
.current-theme {
--light-brighter: 0%;
--light-brighter-ratio: .35;
--light-brighter_: 0%;
--light-brighter_-ratio: .7;
}
This is exactly what the demo card above does.
Both terms add up. Useful to guarantee a minimum gap while still following the headroom that is actually left.
.current-theme {
--dark-darker: -2%;
--dark-darker-ratio: .3;
}
-2% guaranteed, plus 30% of the distance to the bound.
--darker is positive in dark mode. Not a bug: dark ink is light (95%) in dark mode, and -d means more contrast against the background, not darker in absolute terms. The steps describe a relation, which is also why the bounds flip with the mode.
--dark-hue and --light-hue default to var(--brand-hue), with chroma 0.05 and 0.1. Ink and background are tinted by the brand — a deliberate choice, but it surprises: change --brand-hue and every text shifts with it. Set the chromas to 0, or pin the hues explicitly. A generated theme already writes them for you.
Fades are frozen at build time. _, __ and ___ come out of the compiler with 65%, 35% and 15% inlined. $fade-values is overridable in SCSS, not at runtime, and the two axes do not cross: there is no --dark-d at 35%.
20 lines changed in the emitted CSS — the 5 bases × 4 steps, nothing else. The 20 variants were compared in getComputedStyle before and after, in light and in dark mode: zero drift. It all rides on the native var() fallback — as long as a role token is not set, the chain lands back on the original global value.
All variants, all formulas — in /llms.txt.
OKLCH foundations, theme switching, contrast formulas. Plus the auto-theme tool to extract palettes from images.