Eunomia v1.0.0-beta.2
AllAngularReactNext.jsJavaScriptVue
EunomiaSalmonForestVioletOceanGoldFireCustom…
🇬🇧 English🇫🇷 Français

Light & dark via transparency

Plenty of small colored details, a button's hover glow, a pressed background, the soft wash behind a badge or an alert, need to look right on both a light page and a dark one. The obvious way to get there is to hand-pick two versions of every one of those colors, one tuned for each mode. That doubles the design work, and the two versions quietly drift apart the moment only one of them gets updated. Eunomia avoids that entirely: instead of two flat colors, it picks one color and makes it partially see-through, so it blends with whatever surface happens to be behind it. The same single color reads correctly whether that surface is light or dark, with nothing to keep in sync.

The problem this solves

Before, each derived tint was its own opaque hex, calibrated for a white surface:

// eunomia.mixin.scss (before)
--eun-color-primary-bg-hover: #eff3ff;

Dark mode either needed a second, separately hand-picked hex for every one of these tokens across all 7 brands, or, as was actually the case for bg-hover/bg-active, nobody got around to it, and the light-mode value leaked through: a near-white blue patch sitting on a dark surface. Duplication doesn't just cost effort, it silently rots the moment one side gets updated and the other doesn't.

The mechanism

Only the anchor stays a plain opaque hex: -500 for primary/secondary, -100 for each semantic state (success/warning/critical/info). Everything derived from it that's meant to sit as a background wash is expressed as that anchor's RGB channels with an alpha value, computed once in Sass:

// eunomia.mixin.scss (after)
$primary-500: #3d6fff;

--eun-color-primary-500: #{$primary-500};
--eun-color-primary-bg-hover: #{rgba($primary-500, 0.08)};
--eun-color-primary-bg-active: #{rgba($primary-500, 0.08)};
--eun-color-primary-transparent: #{rgba($primary-500, 0.2)};
--eun-color-primary-transparent-hover: #{rgba($primary-500, 0.3)};
--eun-color-primary-transparent-active: #{rgba($primary-500, 0.4)};

This compiles to a literal rgba(61, 111, 255, 0.08). The browser, not the build, does the compositing at paint time: on a white --eun-surface-color that alpha reads as a pale sky-blue wash; on a dark grey one it reads as a muted blue wash. It's the same custom property in both modes, with no --dark counterpart to maintain and no risk of the two drifting apart.

The same four alpha values every brand uses, .08 for bg-hover and bg-active, .2 / .3 / .4 for transparent / transparent-hover / transparent-active, composited live below against a light surface and a dark one. This isn't a fixed example either: every swatch reads this page's own current primary color, live, so it's whatever brand or custom color is active right now, above. Every swatch on a given row is the exact same declaration; only the surface underneath it changes:

Light surface
.08 bg-hover / bg-active
.2 transparent
.3 transparent-hover
.4 transparent-active
Dark surface
.08 bg-hover / bg-active
.2 transparent
.3 transparent-hover
.4 transparent-active

The same wash on the light row and the dark row, unchanged, from declarations that never once mention "light" or "dark". Switch brands or drop in a custom color from the toolbar above (or from the theme generator) and both rows update immediately.

Semantic states follow the same shape in styles/themes/mixins/eunomia-states.mixin.scss: -100 is the opaque anchor, -90/-50/-30 are rgba($base, .32 / .16 / .06). They're brand-independent, so this one file fixes all 7 themes at once; there's no eunomia-success-dark() mixin anymore.

The rule: opaque vs. transparent

Not everything should become alpha. The dividing line is whether the token's contrast is something the library must guarantee, or whether it's decorative:

  • Opaque: anchors (-500, state -100), every -on-* text color, and the rest of the 50–950 scale. These carry text or need a contrast ratio that has to hold regardless of context, so they stay a fixed, verifiable color. "Fixed" doesn't mean one value for both modes though (see below).
  • Transparent: bg-hover, bg-active, transparent-*, and state -90/-50/-30, all expressed as alpha (rgba) values. These are backing washes, never text, so letting them adapt to whatever surface they sit on is a feature, not a risk.

If you're adding a new derived token, ask which bucket it belongs to before picking a format. When in doubt: if text is ever going to sit directly on it, make it opaque.

Opaque anchors are computed per mode, not shared

A brand's -500 (and each state's -100) is picked to look right on a white surface: that's the light-mode value. Used as-is in dark mode, most of them fail WCAG AA: #3d6fff (Eunomia's primary) is 3.2:1 against the dark surface #262d39, when text needs 4.5:1. Every brand's primary failed the same way; several secondaries and every state anchor did too.

The fix keeps the "opaque, single anchor" rule from above; it just stops assuming the anchor is identical across modes. styles/themes/mixins/eunomia-a11y-color.mixin.scss computes WCAG 2.1 contrast at build time and exposes:

// Lightens $color (same hue) until it clears 4.5:1 against $surface.
// Already-accessible colors come back unchanged.
eunomia-dark-accessible($color, $surface: #262d39, $min-contrast: 4.5)

// Picks whichever of near-black/white reads best on $color.
eunomia-on-color($color)

Each brand's -dark.mixin.scss uses them on the seed it already had:

// eunomia-dark.mixin.scss
$primary-500-dark: eunomia-dark-accessible(#3d6fff);

--eun-color-primary-500: #{$primary-500-dark};
--eun-color-on-primary-500: #{eunomia-on-color($primary-500-dark)};

#3d6fff becomes rgb(108, 146, 255) in dark mode: same hue, just lightened until it clears 4.5:1, with on-primary-500 flipping from white to near-black text to match. eunomia-states.mixin.scss does the same for each state's -100 via eunomia-all-dark-states(). Colors that already clear the bar (several secondaries) come back unchanged; this only touches the ones that actually needed it.

The custom-theme JS (Custom theme) mirrors this with a plain-JS equivalent so a generated brand gets the same treatment.

The caveat: it only reads correctly on a neutral surface

An alpha token composites with whatever is actually behind it in the DOM, not with --eun-surface-color by name, just with whatever pixels are there. That's correct and intentional when the element sits directly on the page/card surface. It stops being correct the moment it's stacked on something already colored: a tinted panel, an image, another semi-transparent layer. In that case the wash picks up that color instead, which can look muddy or just wrong.

Practical rule: reach for the alpha tokens for elements that sit on --eun-surface-color (or another neutral, opaque surface token). If a component is going to be nested inside an already-colored container, don't assume the wash will still look right; check it.

See also

Theming overview

How switching brands and modes works site-wide

Prebuilt themes

Palette tables for all 7 brands, both modes

Custom theme

Generating a full scale from your own brand colors