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

Custom theme

Don't want one of the 7 prebuilt themes? Pick Custom… in the toolbar above and choose your own primary and secondary colors: the whole site re-themes from just those two seeds.

Skip ahead to the live theme generator at the bottom of this page: pick two colors and watch the full palette regenerate.

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:

50 #9ab4ff +48%
100 #85a4ff +37%
200 #6f94ff +26%
300 #5480ff +12%
400 #4978ff +6%
500 #3d6fff seed
600 #3968f0 −6%
700 #3662e0 −12%
800 #2d52bd −26%
900 #2646a1 −37%
950 #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, since this static site doesn't itself depend on the published package) for the Custom… option, and writes its output as inline custom properties on <html>. Open your browser's element inspector on <html> while Custom is selected to see them. The live generator at the bottom of this page is a second consumer, for the table + "Copy as CSS" button.

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 Light & dark via transparency for why that matters: it's what makes a custom theme's hover states correct in dark mode without a second palette.

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, and fontFamily are generated and applied; everything else (spacing, radius, shadows, semantic state colors) still comes from whichever base theme class or data-theme you already have applied, since initializeTheme deliberately 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 / darkSurfaceColor exist specifically for that. Other neutrals like --eun-border-color or --eun-text-color are not covered.
  • fontFamily is optional and off by default. Leave it out and --eun-font-family stays exactly as the active theme set it. See "Custom font" below for why custom needs 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.

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.

Copy as CSS
Shade Primary Secondary