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, or usebuildThemeCssinstead to avoid a flash of the base theme on first load.
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;
}
// app/layout.jsx, a Server Component
import { buildThemeCss } from "@eunomia/elements";
const themeCss = buildThemeCss("#3d6fff", "#e5a030");
export default function RootLayout({ children }) {
return (
<html data-brand="custom" data-theme="light">
<head>
<style dangerouslySetInnerHTML={{ __html: themeCss }} />
</head>
<body>{children}</body>
</html>
);
}
Use buildThemeCss rather than initializeTheme here: the brand is
rendered into the HTML on the server, so the page never shows the base
theme while it hydrates. See
<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);
}
}
Loading without a flash
initializeTheme only runs once your app's JavaScript does, after the
browser has already painted the page with the base theme's own colors.
On a first load, visitors see the Eunomia palette for a moment before
it switches to yours.
buildThemeCss is its pure counterpart: the same tokens, for both
light and dark mode, returned as a CSS string instead of being written
onto the DOM. Render it in a <style> in <head>, on the server or at
build time, and the brand is there on first paint:
import { buildThemeCss } from "@eunomia/elements";
buildThemeCss(
primary: string,
secondary: string,
options?: {
surfaceColor?: string,
darkSurfaceColor?: string,
darkSecondaryColor?: string,
inverseSurfaceColor?: string,
darkInverseSurfaceColor?: string,
textColor?: string,
darkTextColor?: string,
pageBackgroundColor?: string,
borderColor?: string,
darkBorderColor?: string,
fontFamily?: string,
brand?: string,
},
): string
| Option | Type | Default | Description |
|---|---|---|---|
surfaceColor |
string |
#ffffff |
Elevated-surface color in light mode |
darkSurfaceColor |
string |
DARK_SURFACE |
Elevated-surface color in dark mode, #262d39 by default |
darkSecondaryColor |
string |
secondary |
Secondary seed in dark mode, used as is |
inverseSurfaceColor |
string |
#0b1620 |
Sets --eun-surface-inverse and a readable --eun-text-on-inverse |
darkInverseSurfaceColor |
string |
inverseSurfaceColor |
inverseSurfaceColor in dark mode |
textColor |
string |
#071621 |
Sets --eun-text-color in light mode, and a readable --eun-text-secondary |
darkTextColor |
string |
grey-800 |
textColor in dark mode |
pageBackgroundColor |
string |
unset | Page background in light mode, --eun-text-secondary also stays readable on it |
borderColor |
string |
#dee0e7 |
Sets --eun-border-color and --eun-divider-color in light mode |
darkBorderColor |
string |
text 12% into surface | borderColor in dark mode |
fontFamily |
string |
unset | Sets --eun-font-family in both modes |
brand |
string |
custom |
The data-brand value the generated rules target |
The output is two rules:
:root[data-brand="custom"] {
--eun-color-primary-50: #9ab4ff;
/* … */
}
:root[data-brand="custom"][data-theme="dark"] {
--eun-color-primary-50: …;
/* … */
}
The :root prefix makes them outrank the prebuilt
[data-brand][data-theme] rules whatever order the stylesheets load
in. Since both modes are in there, switching between light and dark
is just a change of data-theme, with nothing to regenerate.
A few things worth knowing:
data-themehas to be right on first paint too. If the mode comes from a preference only the browser knows, such aslocalStorage, the page still renders in light mode until your app sets it. Read the preference from a cookie on the server, or setdata-themefrom a small blocking inline script in<head>.- Fixed colors don't need any code at all. If your brand never changes at runtime, run this function once in a build script and ship its output as a plain stylesheet.
- If the prerender fails with a duplicate
define, import it from@eunomia/elements/theme.js. Importing@eunomia/elementsin a Server Component registers every component on the server. When a client component also imports the package statically, Next.js evaluates it a second time in its SSR layer, and the second registration throwsthe name "eun-…" has already been used. Thetheme.jssubpath holds the same theming helpers (buildThemeCss,buildPalette,buildTints,initializeTheme) without registering any component, so it avoids that second registration. darkSecondaryColoris for a pale secondary. In dark mode the500step is lightened until it reads on the dark surface, never darkened, so a beige secondary stays beige. That suits a button, not a section background.darkSecondaryColorreplaces the seed in dark mode and skips that check, so its contrast is yours to verify.inverseSurfaceColoris for the elements that stay dark. It sets--eun-surface-inverse, used by a dark toolbar or a footer, and derives--eun-text-on-inversefrom it. Left unset, both keep the base theme's values. SeeNeutral tokens . LikedarkSecondaryColor, it's only available inbuildThemeCss.textColoranddarkTextColorare independent. Dark mode needs a light text, so one mode's color is never reused for the other: a mode left unset keeps the base theme's text. In light mode,--eun-text-secondaryis the text mixed 40% toward the surface, less if that would fall under 4.5:1. When your page background differs from the surface, pass it aspageBackgroundColorso the secondary text also clears 4.5:1 on it. In dark mode, it takes--eun-text-on-dark-secondary, a near-white text. The translucent and sunken tokens follow on their own.- The dark border follows the surface on its own. Left unset, the
dark
--eun-border-coloris the text mixed 12% into the surface, so it stays as discreet as the light one on anydarkSurfaceColor.borderColoranddarkBorderColorreplace it, and the-tintborder follows either way. - Keep
initializeThemefor runtime changes. A color picked by the user after the page has loaded, like this site's Custom… option, is still the job ofinitializeTheme.
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.
Two exports are available once you're happy with the colors:
- Copy brand colors gives the generated palettes, their translucent
hover tokens and the
-tinttokens for the mode currently shown. Paste it after a prebuilt theme stylesheet, which keeps providing every other token. - Copy full theme gives every color token a theme defines, for light
and dark: the generated brand colors plus the grey scale, the
neutrals, their derived tokens, the semantic states and the
decorative scales. Use it as a starting point when you also want to
adjust shared colors such as the text or border color. The
Token reference explains where each group comes from.
| Shade | Primary | Secondary |
|---|