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:
.08
bg-hover / bg-active
.2
transparent
.3
transparent-hover
.4
transparent-active
.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
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 the50–950scale. 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 (
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
How switching brands and modes works site-wide
Palette tables for all 7 brands, both modes
Generating a full scale from your own brand colors