Documentation

Colors

Five roles, three numbers each, and everything else recomposed in OKLCH — opacity fades, brightness steps, per-role tuning and dark mode.

EVA does not store colors. It stores three numbers per role and recomposes everything in OKLCH at runtime.

text
5 roles × { lightness, chroma, hue }
        ↓
--root-<role>              = "L C H"
--<role>                   = oklch(L C H)
--<role>_ __ ___           = same L C H, at 65% / 35% / 15% opacity
--<role>-d -b -d_ -b_      = same C H, lightness shifted

OKLCH is what makes this work: it is perceptually uniform, so shifting lightness does not drag the hue with it. A darker orange stays orange. That is not true in HSL.

The five roles#

RolePurposeDefault lightnessDefault chromaDefault hue
brandPrimary color80%00
accentSecondary color70%00
extraTertiary color60%00
darkInkvar(--current-darkness)0.05var(--brand-hue)
lightSurfacevar(--current-lightness)0.1var(--brand-hue)

The defaults are deliberately colorless — a theme is what gives them values.

Variants#

Each role produces eight variables. Two independent axes: opacity and lightness.

VariableMeaning
var(--brand)The role itself
var(--brand_)65% opacity
var(--brand__)35% opacity
var(--brand___)15% opacity
var(--brand-d)One step of extra contrast
var(--brand-b)One step of less contrast
var(--brand-d_)Two steps of extra contrast
var(--brand-b_)Two steps of less contrast

The same pattern applies to accent, extra, dark and light.

Brightness steps#

Each step is computed from the role's lightness with one formula:

text
lightness = base + absolute offset + (bound − base) × ratio

Four global offsets drive the four steps, and they flip with the mode:

StepTokenLight modeDark modeBound (light)Bound (dark)
-d--darker-5%10%0%100%
-b--brighter10%-5%100%0%
-d_--darker_-15%30%0%100%
-b_--brighter_30%-15%100%0%

--darker being positive in dark mode is not a bug. In dark mode the ink role is light (95%), and -d means "more contrast against the background", not "darker in absolute terms". The steps describe a relation. That is also why the bounds swap with the mode.

Per-role tuning (2.4.0+)#

Before 2.4.0 those four offsets were global: every role got the same step. Since 2.4.0 each role can override its own, and each step can take a proportional share of the remaining headroom instead of a fixed offset.

FormScopeDefaultRole
--<role>-<token>one roleunsetreplaces the global absolute offset
--<role>-<token>-ratioone role0share of the remaining headroom
--<token>-ratioglobal0the same, for all five roles
--<role>-<token>-boundone roleunsetaim at a different bound
--<token>-boundglobalsee tablethe limit the step pushes toward

<role> is brand, accent, extra, dark or light. <token> is darker, brighter, darker_ or brighter_.

Why the ratio exists. OKLCH lightness is clamped to 0%–100%. With --light-lightness: 96.4%:

text
--light-b  = 96.4% + 10% = 106.4%  ->  100%
--light-b_ = 96.4% + 30% = 126.4%  ->  100%

Two steps, one color. And it is symmetric: in dark mode --dark-d and --dark-d_ collapse together on black. On every neutral, in every mode, two of the four steps were unusable — precisely on dark and light, the two most-used roles.

Measured in the browser, in OKLCH lightness, before and after switching to proportional:

beforeafter
--light-b (light mode)1.00.9766
--light-b_ (light mode)1.00.9892
--dark-d (dark mode)1.00.9675
--dark-d_ (dark mode)1.00.985

Recipes#

Tight neutrals, wide accent — only the named step moves, --dark-b_ keeps the global value:

css
.current-theme {
  --dark-darker:  -2%;
  --dark-brighter: 4%;
  --accent-brighter_: 12%;
}

Steps that never saturate — an absolute part of 0 makes the step purely proportional, and it holds in both modes:

css
.current-theme {
  --light-brighter:  0%;  --light-brighter-ratio:  .35;
  --light-brighter_: 0%;  --light-brighter_-ratio: .7;
}

Both terms add up — a guaranteed minimum plus a share of what is left:

css
.current-theme {
  --dark-darker: -2%;
  --dark-darker-ratio: .3;
}

These are plain custom properties. Set them on .current-theme, or on any nested element that also carries .current-theme, to preview a variation in place.

Themes#

A theme is a CSS class that sets the OKLCH triplets. Nothing else.

scss
.theme-myproject {
  --brand-lightness: 62.8%;
  --brand-chroma: 0.258;
  --brand-hue: 29.23;

  --accent-lightness: 55%;
  --accent-chroma: 0.3;
  --accent-hue: 290;

  --extra-lightness: 62%;
  --extra-chroma: 0.12;
  --extra-hue: 25;

  --light-chroma: 0.005;
  --light-hue: 120;
  --dark-chroma: 0.012;
  --dark-hue: 125;
}
html
<body class="current-theme theme-myproject">

Convert your existing HEX palette with the eva-color CLI:

bash
npx eva-color convert "#2f6d3b"
# -> oklch(43.3% 0.103 142.5)

Several themes at once#

Declare as many .theme-<name> blocks as you need and swap the class. Seasonal palettes, brand variants, per-customer themes — all the same mechanism.

scss
.theme-may {
  --brand-lightness: 70%;  --brand-chroma: 0.09;  --brand-hue: 125;
  --accent-lightness: 78%; --accent-chroma: 0.08; --accent-hue: 80;
  --extra-lightness: 62%;  --extra-chroma: 0.12;  --extra-hue: 25;
  --light-chroma: 0.005;   --light-hue: 120;
  --dark-chroma: 0.012;    --dark-hue: 125;
}

.theme-december { /* cold blues */ }
js
function applyTheme(name) {
  const root = document.documentElement;
  root.classList.add('current-theme');
  for (const t of ALL_THEMES) root.classList.remove('theme-' + t);
  root.classList.add('theme-' + name);
}

Two things worth knowing:

  • One source of truth per theme. Set only the triplets. Never redeclare --brand itself; let EVA derive it.
  • Preview swatches scope cleanly. To show a non-active theme's color, wrap the swatch in <div class="current-theme theme-december"> and background: var(--brand) resolves to that theme's color inside the card, whatever the page is running.

Keep --light-chroma low — around 0.005 to 0.015. Higher reads as saturated and breaks the neutral-surface feel.

Dark mode#

EVA ships dark mode. Do not build your own with .dark classes and HEX overrides.

Two variables decide the mode, and they are the only place it is decided:

VariableLightDark
--current-lightness96.4%5%
--current-darkness6.4%95%

light reads the first, dark reads the second. Adding .toggle-theme swaps them:

css
.current-theme.toggle-theme {
  --current-lightness: 5%;
  --current-darkness: 95%;
  --dark-lightness:  var(--current-darkness)  !important;
  --light-lightness: var(--current-lightness) !important;
  --darker: 10%;  --brighter: -5%;
  --darker_: 30%; --brighter_: -15%;
  --darker-bound: 100%;  --brighter-bound: 0%;
  --darker_-bound: 100%; --brighter_-bound: 0%;
}

var(--light) is bright in light mode and dark in dark mode. var(--dark) is the opposite. The two roles literally swap — that is the whole trick, and it is why no color needs redefining.

html
<body class="current-theme theme-myproject toggle-theme">
js
function applyMode(mode) {
  const root = document.documentElement;
  const dark = mode === 'dark'
    || (mode === 'auto' && matchMedia('(prefers-color-scheme: dark)').matches);
  root.classList.toggle('toggle-theme', dark);
}

matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
  if (currentMode === 'auto') applyMode('auto');
});

Bridging semantic tokens#

If your codebase already has --color-bg, --color-text and friends, alias them onto EVA rather than replacing them. They then flip with .toggle-theme and stay tinted by the active theme for free.

scss
.current-theme {
  --color-bg: var(--light);
  --color-surface: oklch(100% var(--light-chroma) var(--light-hue));
  --color-surface-alt: oklch(96% var(--light-chroma) var(--light-hue));

  --color-text: var(--dark);
  --color-text-muted: var(--dark_);
  --color-text-subtle: var(--dark__);
  --color-border: var(--dark___);

  --color-primary: var(--brand);
  --color-primary-ink: var(--brand-d);
  --color-primary-tint: var(--brand___);
}

// Surfaces pinned to literal lightness values don't track --current-lightness,
// so re-pin them for dark mode.
.current-theme.toggle-theme {
  --color-surface: oklch(8% var(--light-chroma) var(--light-hue));
  --color-surface-alt: oklch(12% var(--light-chroma) var(--light-hue));
}

If the app boots client-side and applies the theme classes after mount, keep a static :root { --color-bg: #fafaf7; } fallback to avoid a flash of unthemed content on first paint.

Color utility classes#

With $build-class: true, EVA emits 200 color classes — five properties × five roles × eight variants.

PrefixProperty
._c-color
._bg-background
._bc-border-color
._f-fill (also applies to descendant path elements)
._s-stroke
html
<body class="current-theme theme-myproject _bg-light _c-dark_">
  <span class="_c-brand-d">High-contrast brand text</span>
  <svg class="_f-accent">…</svg>
</body>

The Colors and Auto theme pages let you drag the hue and watch all 200 update live.

Next: Gradients.