CSS Variables in the Age of AI: Letting Models Design Your Themes

I stopped hand-tuning every color token and let AI generate theme palettes on top of CSS variables. The result: faster theming, fewer regressions, and weirder experiments.
CSS Variables in the Age of AI: Letting Models Design Your Themes
Photo by Codioful (Formerly Gradienta) / Unsplash

Why I stopped hard-coding colors everywhere

I used to treat theming as an afterthought. Slap a light theme together, duplicate the file, invert some colors, then spend weeks whack-a-moling contrast issues.

You know the drill. --primary, --primary-dark, --primary-contrast, plus a growing pile of “temporary” tokens that never die.

Then I started wiring AI into my workflow and realised something simple. CSS variables are the perfect boundary between human taste and machine number crunching.

I do not want a model dictating my layout or my component API. But I am completely fine with it crunching theme palettes, contrast tweaks, and seasonal experiments, as long as all of that plugs into a stable set of custom properties.

The core idea: fixed tokens, fluid values

The trick is boring. Which is why it works long term.

You define a stable design token interface in CSS with custom properties. That interface barely changes. AI gets to mess with the values, not the shape.

:root {
  /* semantic tokens */
  --bg: #0b0c10;
  --bg-elevated: #12141b;
  --text: #f5f7ff;
  --text-muted: #a0a4b8;
  --accent: #ffb347;
  --accent-soft: color-mix(in srgb, var(--accent) 14%, transparent);
  --border-subtle: color-mix(in srgb, var(--text) 8%, transparent);
}

body {
  background-color: var(--bg);
  color: var(--text);
}

a {
  color: var(--accent);
}

.card {
  background: var(--bg-elevated);
  border: 1px solid var(--border-subtle);
}

The key decision is that names like --bg, --text, --accent are permanent. They encode meaning, not hex codes.

So I lock the vocabulary, and let AI write the dictionary entries.

What AI actually does in this setup

Here is what I use AI for, concretely, on real projects:

  • Generate full theme palettes from a short mood description
  • Tweak contrast to hit WCAG targets without me manually nudging sliders
  • Produce seasonal or event-based theme variants from existing tokens
  • Translate brand guidelines into a semantic token set that I actually want to maintain

All of that works because the AI never touches the components directly. It produces one thing: a map from semantic token name to CSS value.

Prompting the model like a compiler, not a mood board

If you prompt an AI like a designer, you get Pinterest. If you prompt it like a compiler, you get usable output.

Here is a trimmed-down version of the system prompt I use when I want a fresh theme.

You generate themes for a design system that uses CSS custom properties.

Output JSON only. No comments, no extra fields.

Fields:
  - name: short human-readable theme name
  - tokens: map of CSS variable names to values

Rules:
  - Use ONLY these tokens:
    --bg, --bg-elevated, --bg-raised,
    --text, --text-muted, --text-on-accent,
    --accent, --accent-soft,
    --border-subtle, --border-strong,
    --danger, --warning, --success
  - Values must be valid CSS color values: hex, rgb(), hsl(), or oklch().
  - Ensure sufficient contrast for body text & buttons.

Then the actual user prompt stays short.

Generate a theme inspired by late-night baseball under stadium lights.
Dark UI, punchy accent color, readable text, no pure black.

The model replies with something like this.

{
  "name": "Stadium Night",
  "tokens": {
    "--bg": "oklch(18% 0.03 260)",
    "--bg-elevated": "oklch(24% 0.04 260)",
    "--bg-raised": "oklch(30% 0.04 260)",
    "--text": "oklch(95% 0.01 260)",
    "--text-muted": "oklch(78% 0.03 260)",
    "--text-on-accent": "#050308",
    "--accent": "oklch(72% 0.18 80)",
    "--accent-soft": "oklch(32% 0.05 80 / 0.5)",
    "--border-subtle": "oklch(32% 0.03 260)",
    "--border-strong": "oklch(46% 0.06 260)",
    "--danger": "oklch(60% 0.2 25)",
    "--warning": "oklch(72% 0.14 80)",
    "--success": "oklch(68% 0.12 145)"
  }
}

I do not copy paste this by hand. I wire it into a small script.

From JSON to CSS: the tiny build step

The glue code is boring TypeScript. That is good. Boring code usually survives.

I keep themes as JSON files in the repo.

// themes/stadium-night.json
{
  "name": "Stadium Night",
  "tokens": {
    "--bg": "oklch(18% 0.03 260)",
    "--bg-elevated": "oklch(24% 0.04 260)",
    "--bg-raised": "oklch(30% 0.04 260)",
    "--text": "oklch(95% 0.01 260)",
    "--text-muted": "oklch(78% 0.03 260)",
    "--text-on-accent": "#050308",
    "--accent": "oklch(72% 0.18 80)",
    "--accent-soft": "oklch(32% 0.05 80 / 0.5)",
    "--border-subtle": "oklch(32% 0.03 260)",
    "--border-strong": "oklch(46% 0.06 260)",
    "--danger": "oklch(60% 0.2 25)",
    "--warning": "oklch(72% 0.14 80)",
    "--success": "oklch(68% 0.12 145)"
  }
}

Then a build script turns that into real CSS.

// scripts/build-themes.ts
import fs from "node:fs";
import path from "node:path";

const themesDir = path.join(process.cwd(), "themes");
const outFile = path.join(process.cwd(), "src/styles/themes.css");

const files = fs.readdirSync(themesDir).filter(f => f.endsWith(".json"));

let css = "/* generated, do not edit by hand */\n\n";

for (const file of files) {
  const raw = fs.readFileSync(path.join(themesDir, file), "utf8");
  const theme = JSON.parse(raw) as {
    name: string;
    tokens: Record<string, string>;
  };

  const themeName = path.basename(file, ".json");
  css += `.theme-${themeName} {\n`;

  for (const [key, value] of Object.entries(theme.tokens)) {
    css += `  ${key}: ${value};\n`;
  }

  css += `}\n\n`;
}

fs.writeFileSync(outFile, css);

This gives me classes I can slap on <html> or <body>.

<html class="theme-stadium-night">

One decision I regret from older projects is putting tokens directly on :root with no scoping. It feels simpler, until you want a preview grid of themes or server-side A/B tests for appearance.

Theme classes scale better.

Letting users and AI pick themes together

Once themes live in CSS as classes, you can let different actors toggle them.

  • User preference in localStorage
  • OS prefers-color-scheme as a baseline
  • AI suggestions based on context or time

Here is a tiny snippet I use to apply a theme class with a fallback chain.

<script>
  const root = document.documentElement;

  const saved = window.localStorage.getItem("theme");
  const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;

  function apply(theme) {
    root.classList.forEach(c => {
      if (c.startsWith("theme-")) root.classList.remove(c);
    });
    root.classList.add(`theme-${theme}`);
  }

  if (saved) {
    apply(saved);
  } else if (prefersDark) {
    apply("stadium-night");
  } else {
    apply("default-light");
  }
</script>

The AI part fits into this just fine. When I ask a model for a "focus" theme at 5 AM coding sessions, it just means writing a new JSON file, running the build, and letting the usual logic pick it if configured.

Where AI is bad at themes

AI is surprisingly bad at respecting brand constraints unless you are mean in the prompt.

If you only say “use brand blue #123456”, you will end up with an accent palette that quietly drifts toward different hues or saturations that break the logo.

What works better for me:

  • Freeze certain tokens, and forbid the model from touching them
  • Let AI fill gaps and generate supporting roles around them

The prompt looks more like this.

Here are non-editable core tokens:

--brand: #123456
--brand-on: #ffffff

You may NOT change those values.

You MAY define the rest of this list, and you MUST keep them harmonious with --brand:

--bg, --bg-elevated, --bg-raised,
--text, --text-muted,
--accent, --accent-soft,
--border-subtle, --border-strong

I validate on my side too. The script fails the build if the model tries to override --brand.

const frozen = new Set(["--brand", "--brand-on"]);

for (const [key, value] of Object.entries(theme.tokens)) {
  if (frozen.has(key)) {
    throw new Error(`Theme ${themeName} attempted to override frozen token ${key}`);
  }
  css += `  ${key}: ${value};\n`;
}

Trust but verify. Mostly verify.

Adaptive themes without a mess of media queries

Once everything is a custom property, adaptive behaviour becomes much easier.

Instead of sprinkling media queries all over components, I use them to redefine tokens.

.theme-stadium-night {
  --card-radius: 12px;
  --gap: 16px;
}

@media (min-width: 900px) {
  .theme-stadium-night {
    --card-radius: 18px;
    --gap: 24px;
  }
}

.card {
  border-radius: var(--card-radius);
  gap: var(--gap);
}

AI can absolutely produce breakpoint-aware sets of values too. I have had models output per-size tokens.

{
  "name": "Stadium Night",
  "tokens": {
    "base": { "--gap": "16px" },
    "md":   { "--gap": "20px" },
    "lg":   { "--gap": "24px" }
  }
}

The build script just has to understand that structure and compile it to media queries.

I am not letting AI write raw CSS, because that quickly drifts into specificity hell. Tokens keep it boring.

Letting users describe themes in plain language

The fun part is exposing a bit of this to actual users.

On one project I added a tiny “mood to theme” panel behind a feature flag. It lets you type something like:

  • “high-contrast hacker terminal, but readable”
  • “soft sunrise pastel for long reading sessions”

That text goes through a server-side call to an LLM with the strict JSON contract I showed earlier. The result becomes a new theme entry persisted for that user.

The guards:

  • Schema validation on the JSON
  • Contrast checks with a small OKLCH-based utility
  • Hard limits on how wild hues can drift from brand colors

If it fails validation, I show an error and fall back to a safe default. No half-broken styles leaking through.

I think this is where AI theming actually makes sense. Not random generative UIs, but thin, constrained layers that let people nudge a stable system into shapes that fit how they work.

What this buys me as a developer

After a few months using this pattern across client work and my own projects, a few benefits stand out.

  • Way faster iteration. I can generate three or four plausible themes in a morning, test them on real content, and keep the one that does not destroy my eyes.
  • Less design debt. Because the interface to the system is stable, I can refactor values aggressively without touching components.
  • Safer experiments. Seasonal themes, A/B tests, per-tenant branding. All just different sets of tokens, not forks of the CSS.

The cost is a bit of upfront work. You need a token vocabulary that is actually semantic. “primary-500” and “primary-600” do not tell you anything. “bg-elevated” and “text-muted” do.

Once that is in place, AI becomes a decent assistant. Not a designer, but a very fast colorist who follows rules and never complains about trying 40 variations of “late night baseball under light rain”.

If you build something like this

If you try this approach, start tiny.

  • Pick 10 to 15 semantic tokens that describe your current theme
  • Refactor your CSS to use only those tokens
  • Write a strict prompt that outputs JSON with values for exactly that list
  • Add a boring build step that compiles JSON to a .theme-x class

From there, you can layer on adaptive behaviour, user prompts, or brand constraints without rewriting your components every time a model has a new opinion about blue.

CSS variables give you the stable contract. AI just negotiates the values. That separation is the whole trick.

Subscribe to my newsletter

Subscribe to my newsletter to get the latest updates and news

Member discussion