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.

Brand HUE 169° --brand-hue
Accent HUE 10° --accent-hue
Extra HUE 200° --extra-hue

All swatches

5 base colors × 8 variants. Same naming pattern, every time.

Brand
brand
brand_
brand__
brand___
brand-d
brand-b
brand-d_
brand-b_
Accent
accent
accent_
accent__
accent___
accent-d
accent-b
accent-d_
accent-b_
Extra
extra
extra_
extra__
extra___
extra-d
extra-b
extra-d_
extra-b_
Dark / Light
dark
dark_
dark__
dark___
light
light_
light__
light___
Naming convention
_c-brand     /* color */
_bg-brand    /* background */
_bc-brand    /* border-color */
Opacity modifiers
brand     /* full */
brand_    /* 65% */
brand__   /* 35% */
brand___  /* 15% */
Brightness modifiers
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.

The pivot

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%;
How --dark and --light wire in

--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.

Light mode (forced)
light
dark
brand
Dark mode (forced)
light
dark
brand
Manual toggle

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">
Auto (prefers-color-scheme)

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
);
The rule

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.

Two steps, one color

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.

The formula

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.

Absolute vs proportional

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.

Absolute — the default
light
light-b
light-b_

Both bright steps saturate. One color, printed twice.

Proportional — opt-in
light
light-b
light-b_

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;
The tokens

<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 */
Recipes
Tight neutrals, wide accent

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_.

Steps that never saturate

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.

A fixed floor plus a share

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.

Three things to know

--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%.

Nothing moved

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.

Full reference

All variants, all formulas — in /llms.txt.

OKLCH foundations, theme switching, contrast formulas. Plus the auto-theme tool to extract palettes from images.