Theming an Angular App from a CMS with CSS Variables

Let an admin pick the brand colours and have the whole Angular site follow, including server-rendered HTML, dark mode, readable text on any colour and Tailwind opacity modifiers. A practical CSS custom properties setup.

Cover for the article Theming an Angular App from a CMS with CSS Variables

On this portfolio the brand colours are not in the code. They live in the database: the admin panel has a list of themes, one is active, and the website follows it without a rebuild. The same pattern works for any product where a client wants to change their colours themselves. Here is how it fits together in Angular.

Three colours in, everything else derived

A theme is just three hex values: primary (buttons, links, focus rings), secondary (dark bands) and accent (decorative tints). Every other colour in the UI is derived from those with CSS, so the admin cannot pick a combination that leaves half the site unstyled.

The defaults live in plain CSS custom properties, and the derived tokens reference them:

:root {
  --primary: #fd853a;
  --secondary: #141414;
  --accent: #feb173;
  --on-primary: #141414;

  --primary-hover: color-mix(in srgb, var(--primary) 88%, #000000);
  --primary-soft: color-mix(in srgb, var(--primary) 12%, transparent);
}

.btn-primary {
  background: var(--primary);
  color: var(--on-primary);
}
.btn-primary:hover {
  background: var(--primary-hover);
}

color-mix() is what makes this work. Hover states, soft backgrounds and borders are computed from whatever --primary currently is, so changing one variable updates all of them.

Applying the palette, on the server too

The website loads the site settings (including the active theme) when it starts. A small service validates the colours and writes them onto <html>:

applyBrand(palette: Partial<BrandPalette> | null | undefined): void {
  if (!palette) return;
  for (const key of ['primary', 'secondary', 'accent'] as const) {
    const value = palette[key];
    if (!isHexColor(value)) continue; // keep the CSS default
    const hex = value.toUpperCase();
    this.root.style.setProperty(`--${key}`, hex);
    this.root.style.setProperty(`--on-${key}`, readableTextOn(hex));
  }
}

this.root is inject(DOCUMENT).documentElement, not window.document. That matters with SSR: during server rendering Angular gives you a server-side DOM, the inline style is written there, and it is serialised into the HTML. The very first response already has the right colours, so there is no flash of the default orange before hydration.

Invalid values are ignored rather than applied. A colour that fails /^#[0-9a-f]{6}$/i simply leaves the stylesheet default in place, so a bad record can never break the page.

Readable text on any colour

If an admin can choose any primary colour, white button text will sometimes be unreadable. Instead of hoping, the service picks the text colour with the WCAG contrast formula:

function relativeLuminance(hex: string): number {
  const [r, g, b] = [1, 3, 5]
    .map((i) => parseInt(hex.slice(i, i + 2), 16) / 255)
    .map((c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
  return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}

function contrastRatio(a: string, b: string): number {
  const [light, dark] = [relativeLuminance(a), relativeLuminance(b)].sort((x, y) => y - x);
  return (light + 0.05) / (dark + 0.05);
}

/** White or near-black, whichever contrasts better with the background. */
export function readableTextOn(background: string): '#FFFFFF' | '#141414' {
  return contrastRatio(background, '#FFFFFF') >= contrastRatio(background, '#141414')
    ? '#FFFFFF'
    : '#141414';
}

The result goes into --on-primary, and every component uses that variable for text on a primary background. A light orange gets near-black text, a deep blue gets white, and nobody has to remember to check.

Making Tailwind follow the variables

Pointing Tailwind colours at var(--primary) works, but it breaks opacity modifiers such as bg-primary/10. Tailwind 3 supports an <alpha-value> placeholder, and combining it with color-mix() keeps modifiers working with any colour format:

// tailwind.config.js
const color = (name) =>
  `color-mix(in srgb, var(--${name}) calc(<alpha-value> * 100%), transparent)`;

module.exports = {
  darkMode: ['selector', '[data-theme="dark"]'],
  theme: {
    extend: {
      colors: {
        primary: { DEFAULT: color('primary'), hover: color('primary-hover') },
        'on-primary': color('on-primary'),
      },
    },
  },
};

Now bg-primary, text-on-primary and ring-primary/40 all follow the active theme.

Light and dark mode as a second layer

Brand colours and light or dark mode are separate concerns. The palette is the admin's choice; the mode is the visitor's. I keep the mode on a data-theme attribute and redefine the neutral tokens (backgrounds, text, borders) under [data-theme="dark"], while the brand variables stay the same.

The resolved mode has to be known before the first paint, which Angular cannot do in time. A tiny inline script in index.html reads the visitor's saved choice, falls back to the admin's default (rendered by the server into a data-site-mode attribute) and then to the OS setting:

<script>
  (function () {
    var root = document.documentElement;
    var modes = ['light', 'dark', 'system'];
    var preference = null;
    try { preference = localStorage.getItem('theme-preference'); } catch (e) {}
    if (modes.indexOf(preference) < 0) preference = root.getAttribute('data-site-mode');
    if (modes.indexOf(preference) < 0) preference = 'system';
    var mode = preference === 'system'
      ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
      : preference;
    root.setAttribute('data-theme', mode);
  })();
</script>

After hydration a service takes over, and signals express the precedence rule directly:

private readonly explicitPreference = signal<ThemePreference | null>(null);
private readonly siteDefault = signal<ThemePreference>('system');
private readonly systemMode = signal<'light' | 'dark'>('light');

readonly preference = computed(() => this.explicitPreference() ?? this.siteDefault());
readonly theme = computed(() => {
  const preference = this.preference();
  return preference === 'system' ? this.systemMode() : preference;
});

constructor() {
  effect(() => this.root.setAttribute('data-theme', this.theme()));
}

A matchMedia change listener updates systemMode, so a visitor who follows the OS setting switches live when their system does.

A few rules that keep it maintainable

  • No hard-coded colours in components. Everything goes through tokens, so a new theme never needs a code change.
  • Validate on both ends. The API only stores #RRGGBB values, and the client ignores anything else.
  • Derive, don't store. Hover, soft and text colours are computed, so the admin manages three values, not twenty.
  • Treat storage as optional. localStorage can throw in private modes; theming must never break the page.

The result is a site whose look the owner controls from the admin panel, rendered correctly on the first byte, and readable whatever colours they pick.