Custom theme
Don't want one of the
Skip ahead to the
How it works
Every color the library uses, a button's background, a badge's
border, resolves to one shade in an 11-step scale (50 lightest
through 950 darkest, 500 the color you'd actually pick in a
swatcher) plus a matching on-* text color for whenever that shade
is used as a background. A full theme needs two of these scales,
primary and secondary, so 22 shades and 22 on-* pairs in total,
all generated from just the two seed colors you provide.
The scale: lighten and darken
Every step but 500 is the seed color mixed toward white (steps
50-400) or black (600-950) by a fixed percentage. "Lighten
48%" means each RGB channel moves 48% of the distance from its
current value to 255 (white); "darken 48%" moves it 48% of the way
to 0 (black) instead:
function lighten(hex, amount) {
const [r, g, b] = hexToRgb(hex);
const mix = (c) => Math.min(255, Math.round(c + (255 - c) * (amount / 100)));
return rgbToHex(mix(r), mix(g), mix(b));
}
function darken(hex, amount) {
const [r, g, b] = hexToRgb(hex);
const mix = (c) => Math.max(0, Math.round(c * (1 - amount / 100)));
return rgbToHex(mix(r), mix(g), mix(b));
}
The percentage is fixed per step, never derived from the seed
itself: 50 is always "lighten 48%" and 950 is always "darken
48%", whatever color you start from. Only 500 stays untouched:
it's the seed, unmodified. Here's that scale for real, not
simulated: every swatch below reads its color straight from this
page's own live primary tokens, the exact ones every component on
this site already uses. Switch brands or drop in a custom color from
the toolbar above, or from the generator further down, and the whole
ramp updates immediately:
#9ab4ff
+48%
#85a4ff
+37%
#6f94ff
+26%
#5480ff
+12%
#4978ff
+6%
#3d6fff
seed
#3968f0
−6%
#3662e0
−12%
#2d52bd
−26%
#2646a1
−37%
#203a85
−48%
The "on" color: readable text at every step
The matching text color for each shade is chosen by comparing real
WCAG contrast rather than by a fixed lightness threshold. For every
shade, the algorithm works out the actual rendered contrast ratio
against near-black text (#000000de) and against white text, then
keeps whichever of the two wins.
function pickOnColor(hex) {
const darkContrast = onColorDarkContrast(hex);
const lightContrast = contrastRatio(hex, "#ffffff");
if (lightContrast > darkContrast) {
return "#ffffff";
}
return darkContrast >= MIN_CONTRAST ? "#000000de" : "#000000";
}
A plain lightness threshold would pick the "obviously" light or dark side of a shade, but a mid-tone shade can still fall short of the 4.5:1 WCAG AA minimum even with the better of the two choices. When that happens, the near-black option's transparency is dropped in favor of fully opaque black, since opaque black always contrasts more than its 87% variant against anything lighter than mid-grey. Watch for where the ramp above flips from dark text to light text: that crossover step isn't fixed, since it depends on the seed's own lightness, so it lands somewhere different for every brand and every custom color.
Where this actually runs
buildPalette and buildTints below are real exports of
@eunomia/elements, not docs-only sample code, the same shade ramp and
-tint math walked through above. They're pure, no DOM access, no
component state, so they run the same in a build script, on the
server, or in the browser:
import { buildPalette, buildTints } from "@eunomia/elements";
const primaryShades = buildPalette("#3d6fff", /* isDark */ false);
const tints = buildTints("#3d6fff", /* isDark */ false);
The individual steps shown earlier, lighten, darken, pickOnColor,
darkAccessible, stay internal to the library rather than each being
its own export, since they only ever exist to be composed by these
two.
This site's toolbar is one consumer of it: scripts/theme.js imports the
same algorithm (mirrored locally as
scripts/eunomia-theme-generator.js<html>. Open your browser's element inspector on <html> while
Custom is selected to see them. The
Hover and pressed states
The bg-hover/bg-active/transparent-* tokens are generated too,
the same way the prebuilt themes' Sass does it: not a 12th lighten
step, but an alpha overlay of the seed color (rgba(r, g, b, alpha)),
at the same .08 / .2 / .3 / .4 steps as every prebuilt theme. An
overlay works over any surface it sits on, light or dark, where a
fixed lighten/darken step would only ever look right against one.
See
Staying accessible in dark mode
In dark mode, the 500 step is contrast-checked too, not just
lightened/darkened blindly like the other ten. Take Eunomia's own
primary seed as a concrete example: #3d6fff against the
#262d39 dark surface only reaches a 3.2:1 contrast ratio, well
short of the 4.5:1 WCAG AA minimum most text needs. So in dark mode,
theme.js lightens the seed (same hue) in 3% steps until it clears
that bar: 24% lighter here, landing on #6c92ff at 4.7:1, and flips
on-{type}-500 between white and near-black text to match. Switch
this page to dark mode and watch the ramp's own 500 swatch shift
the same way, for whichever color is active. This is a JS port of
the same
eunomia-dark-accessible() Sass function every prebuilt theme's
-dark.mixin.scss uses, so a custom brand gets the same accessible
dark mode as the 7 built-in ones, not a lesser version of it.
Using it in your own app
buildPalette and buildTints, the same two exports "Where this
actually runs" above already introduced, are kept pure on purpose, no
DOM access, so they run anywhere: server-side, in a build script, or
in a browser. Applying their result to the page is the one part of
this algorithm that isn't pure, so it lives in its own export instead,
initializeTheme, doing exactly what "Where this actually runs"
described theme.js doing:
Signature
import { initializeTheme } from "@eunomia/elements";
initializeTheme(
primary: string,
secondary: string,
isDark?: boolean,
surfaceColor?: string,
darkSurfaceColor?: string,
fontFamily?: string,
): void
| Parameter | Type | Default | Description |
|---|---|---|---|
primary |
string |
none | Hex seed for the --eun-color-primary-* scale |
secondary |
string |
none | Hex seed for the --eun-color-secondary-* scale |
isDark |
boolean |
false |
Runs the 500 step through the dark-mode accessibility check |
surfaceColor |
string |
#ffffff |
Elevated-surface color in light mode |
darkSurfaceColor |
string |
DARK_SURFACE |
Elevated-surface color in dark mode, #262d39 by default |
fontFamily |
string |
unset | Sets --eun-font-family; left out, the current font is kept |
Each color needs a hex string: that's what buildPalette/buildTints
themselves expect internally, since they reach hexToRgb directly
rather than going through a general CSS color parser.
A few things worth knowing:
- Colors, surface, and font are all it touches.
primary,secondary,surfaceColor, andfontFamilyare generated and applied; everything else (spacing, radius, shadows, semantic state colors) still comes from whichever base theme class ordata-themeyou already have applied, sinceinitializeThemedeliberately doesn't override those. - Surface color is the one neutral it does let you set. Elevated
surfaces (modals, drawers, popovers, dropdown panels, table headers)
legitimately differ between light and dark, so
surfaceColor/darkSurfaceColorexist specifically for that. Other neutrals like--eun-border-coloror--eun-text-colorare not covered. fontFamilyis optional and off by default. Leave it out and--eun-font-familystays exactly as the active theme set it. See "Custom font" below for whycustomneeds this at all.- It's client-side only. It writes directly to
document.documentElement. In an SSR framework, call it after mount, not during render.
Custom font
Every theme ships with the same base typography as Eunomia's own
brand: Mulish, self-hosted, applied through --eun-font-family. The
custom brand reuses this too, since color is the only thing it's
meant to replace on its own: the font, spacing, radius, and shadows
still come from that shared base, so a custom brand stays visually
consistent with the rest of the design system by default.
To swap the font for your own, pass it as initializeTheme's sixth
argument instead of reaching for a CSS override:
initializeTheme(
"#3d6fff",
"#e5a030",
isDarkMode,
"#ffffff",
"#262d39",
"'Inter', sans-serif",
);
This writes --eun-font-family onto document.documentElement, the
exact property every component already reads its font from, so the
change applies everywhere at once, the same way a color change does.
If you'd rather set --eun-font-family directly in CSS, keep the
declaration on the same element where the variable it points to is
actually defined. A CSS custom property resolves its own var()
value at the point where it's declared, not at the point where it's
read, so pointing --eun-font-family at a framework-provided variable
from a higher element than where that variable exists resolves to
nothing there. A font loader that only defines its variable on
<body>, for instance, needs the override declared on <body> too,
not on <html> above it.
Usage
Pick a framework in the toolbar above and the snippet below adapts:
initializeTheme("#3d6fff", "#e5a030", isDarkMode);
Call it once the DOM is ready, after DOMContentLoaded, or right
after your app mounts. Calling it during a server render (no
document) throws.
import { initializeTheme } from "@eunomia/elements";
document.addEventListener("DOMContentLoaded", () => {
initializeTheme("#3d6fff", "#e5a030", isDarkMode);
});
import { useEffect } from "react";
import { initializeTheme } from "@eunomia/elements";
function ThemeInitializer({ isDarkMode }) {
useEffect(() => {
initializeTheme("#3d6fff", "#e5a030", isDarkMode);
}, [isDarkMode]);
return null;
}
"use client";
import { useEffect } from "react";
import { initializeTheme } from "@eunomia/elements";
export function ThemeInitializer({ isDarkMode }) {
useEffect(() => {
initializeTheme("#3d6fff", "#e5a030", isDarkMode);
}, [isDarkMode]);
return null;
}
Render <ThemeInitializer /> once, near the root layout. It has to be
a client component: initializeTheme needs document, which
doesn't exist during the server render.
<script setup>
import { onMounted } from "vue";
import { initializeTheme } from "@eunomia/elements";
const props = defineProps(["isDarkMode"]);
onMounted(() => {
initializeTheme("#3d6fff", "#e5a030", props.isDarkMode);
});
</script>
import { Inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { initializeTheme } from '@eunomia/elements';
constructor(@Inject(PLATFORM_ID) platformId: object) {
if (isPlatformBrowser(platformId)) {
initializeTheme('#3d6fff', '#e5a030', this.isDarkMode);
}
}
Build your theme
This isn't a separate preview: it's the toolbar's own Custom…
picker, just embedded here at full size. Pick two colors below and
every shade, plus its matching on-* text color, regenerates
instantly, running the exact algorithm this page just walked
through, and applies to this entire site, exactly as if you'd
picked those colors from the toolbar above. It also starts pre-filled
with whatever theme is currently active, so reload with a different
brand selected and it picks up right where that theme left off. Flip
the toolbar's dark/light toggle above to see the 500 row's own
accessibility check kick in live.
| Shade | Primary | Secondary |
|---|