Tailwind CSS v4 Dynamic Themes Using Semantic CSS Tokens

· SitePoint ·

15 min read Original article ↗

SitePoint Team

The AI briefing for Developers

Stay up to date with AI tools, model releases, and developer workflows that matter.

Weekly. Free. One click to leave.

Tailwind CSS v4 Dynamic Themes Using Semantic CSS Tokens

How to Build Dynamic Themes in Tailwind CSS v4

  1. Install Tailwind CSS v4 with PostCSS or the Vite plugin and create your entry CSS file.
  2. Register semantic color tokens (primary, surface, accent, etc.) using the @theme inline directive with var() references.
  3. Define light-mode color values for each token as CSS custom properties on :root.
  4. Override those properties inside @media (prefers-color-scheme: dark) for automatic dark mode support.
  5. Add a .dark class selector with @custom-variant for manual dark mode toggling via JavaScript.
  6. Create brand themes by scoping variable reassignments under data attributes like [data-theme="ocean"].
  7. Build components using only semantic utilities (bg-primary, text-on-surface) — no dark: variants needed.
  8. Switch themes at runtime by setting data-theme on the root element with a small JavaScript function.

Managing dynamic themes in Tailwind CSS has historically meant wrestling with verbose, repetitive markup (e.g., bg-blue-600 dark:bg-blue-400 theme-ocean:bg-teal-500 on every themed element). Every element that needed to respond to dark mode required a dark: variant prefix, and introducing brand themes on top of that created a combinatorial explosion: a button needing 3 themes × 2 modes means 6 class variants per color property. Tailwind CSS v4 semantic tokens offer a fundamentally different approach. By using the new @theme inline directive and CSS custom properties, developers can build a system where a single utility class like bg-primary automatically resolves to the correct color across any number of themes and color schemes, without a single dark: variant in the markup.

This tutorial walks through building that system from scratch. By the end, readers will have a complete, copy-paste-ready CSS configuration and a demo card component that adapts across multiple brand themes and light/dark modes.

Table of Contents

Prerequisites and Project Setup

What You Need

This tutorial requires Tailwind CSS v4, installed via the PostCSS plugin or the standalone CLI. Readers should have a basic understanding of CSS custom properties (var() syntax) and a simple project environment to test in. The scaffold below uses PostCSS, which works with plain HTML projects and most bundlers. For Vite projects, use @tailwindcss/vite instead of @tailwindcss/postcss — install it with npm install @tailwindcss/vite and configure it in vite.config.ts. For Next.js, confirm that your Next.js version supports PostCSS configuration before proceeding.

You will also need Node.js 18 or later and a modern browser that supports OKLCH colors (Chrome 111+, Firefox 113+, Safari 15.4+). If you need to support older browsers, add hex or RGB fallback values alongside the OKLCH values shown below.

Scaffold a Minimal Project

Start with a clean installation to ensure every subsequent code block pastes cleanly:

npm install tailwindcss @tailwindcss/postcss @tailwindcss/cli postcss

Verify the installed version:

npx tailwindcss --version

The output should show a 4.x version, for example tailwindcss v4.3.3 (the version I tested with). If it shows 3.x, the install did not target v4. The @tailwindcss/cli package provides the tailwindcss command used for the build step later in this article.

Create a postcss.config.mjs at the project root:

// postcss.config.mjs — complete file
export default {
  plugins: {
    "@tailwindcss/postcss": {},
  },
};

Note: the .mjs extension requires your project to support ES modules. If your project uses CommonJS (no "type": "module" in package.json), rename this file to postcss.config.js and replace export default with module.exports =.

Then create the entry CSS file (e.g., src/app.css) with a single line:

@import "tailwindcss";

This is the baseline. Everything that follows goes into this CSS file, below the import.

Understanding @theme inline and Semantic Tokens in Tailwind v4

What @theme inline Does

Tailwind v4's @theme directive replaces the theme section of tailwind.config.js in CSS. When you use @theme inline, Tailwind writes the token's var() reference directly into each generated utility instead of emitting a --color-primary theme variable and pointing the utility at it. In the compiled output, bg-surface becomes background-color: var(--bg-surface, oklch(98% 0.005 260)).

The mental model is this: a token name registered as --color-primary inside @theme inline becomes available as a utility class (bg-primary, text-primary, border-primary, and so on). The value assigned to that token can itself be a var() reference pointing to another CSS custom property defined elsewhere. This indirection is what makes the entire theming system possible. The utility class stays fixed; the underlying variable changes based on context.

Why Semantic Tokens Beat Literal Colors

Consider the difference between bg-blue-600 dark:bg-blue-400 and simply bg-primary. The first approach hardcodes appearance into markup. Every element carries the burden of knowing what color it should be in every mode. The second approach encodes intent: this element should use the primary color, whatever that means in the current theme and color scheme. Design decisions stay decoupled from component markup entirely.

The official Tailwind v4 theme documentation at https://tailwindcss.com/docs/theme covers the full @theme API for readers who want to explore beyond color tokens.

Defining Your Semantic Color Tokens

Choosing a Token Vocabulary

A well-designed token set is small and role-based. The following vocabulary covers most UI needs without becoming unwieldy.

primary and on-primary form the core action pair: primary is the main brand or action color (buttons, links, highlights), while on-primary is the text or icon color placed on top of primary backgrounds to ensure contrast.

For layout surfaces, surface provides the default background for cards, panels, and page areas, and on-surface supplies the body text color rendered against those backgrounds.

The remaining tokens handle supporting roles: accent for badges, indicators, or secondary actions; stroke for border color on dividers and input outlines; and muted for subdued text like captions, timestamps, or helper text.

Registering Tokens with @theme inline

Add this block to the stylesheet after the Tailwind import. Each var() call includes a fallback value so that a missing variable assignment produces a visible default rather than a transparent element:

@theme inline {
  --color-primary:    var(--bg-primary,    oklch(55% 0.25 260));
  --color-on-primary: var(--text-on-primary, oklch(100% 0 0));
  --color-surface:    var(--bg-surface,    oklch(98% 0.005 260));
  --color-on-surface: var(--text-on-surface, oklch(20% 0.02 260));
  --color-accent:     var(--bg-accent,     oklch(65% 0.2 330));
  --color-stroke:     var(--border-color,  oklch(85% 0.01 260));
  --color-muted:      var(--text-muted,    oklch(55% 0.01 260));
}

This is the indirection layer. Tailwind now generates utility classes (bg-primary, text-on-surface, border-stroke, etc.) whose computed values point to bare CSS custom properties like --bg-primary and --text-on-primary. Those properties do not have concrete color values yet. That comes next.

Verify your chosen token names do not overlap with Tailwind v4's generated variable set by inspecting the compiled output. Run npx tailwindcss -i src/app.css -o out.css and search for .border-stroke and .text-muted to confirm your utilities were generated. Because the tokens use inline, the --color-* names themselves do not appear in the output.

Creating Light and Dark Variable Sets

Default (Light) Theme Variables

Define the raw color values that the semantic tokens resolve to by default. OKLCH color syntax takes advantage of Tailwind v4's modern color handling and provides perceptually uniform color manipulation. No tailwind.config.js required.

:root {
  --bg-primary: oklch(55% 0.25 260);
  --text-on-primary: oklch(100% 0 0);
  --bg-surface: oklch(98% 0.005 260);
  --text-on-surface: oklch(20% 0.02 260);
  --bg-accent: oklch(65% 0.2 330);
  --border-color: oklch(85% 0.01 260);
  --text-muted: oklch(55% 0.01 260);
}

Once this block is in place, every semantic utility class resolves to a concrete light-mode color.

Dark Mode Overrides with prefers-color-scheme

To support automatic dark mode based on the user's operating system preference, reassign the same custom properties inside a media query:

@media (prefers-color-scheme: dark) {
  :root {
    --bg-primary: oklch(70% 0.2 260);
    --text-on-primary: oklch(10% 0.02 260);
    --bg-surface: oklch(15% 0.01 260);
    --text-on-surface: oklch(90% 0.005 260);
    --bg-accent: oklch(75% 0.18 330);
    --border-color: oklch(30% 0.02 260);
    --text-muted: oklch(60% 0.01 260);
  }
}

This requires no Tailwind configuration change. No dark: variant appears anywhere in the HTML. The browser media query swaps the variable values, and every utility that references them updates automatically.

Optional: Adding a Manual Dark Toggle with @custom-variant

For JavaScript-driven dark mode toggling (common in apps that let users override their OS preference), register a custom variant and a matching class-based selector block. The .dark selector uses :root.dark with :is() to ensure it has higher specificity than the @media (prefers-color-scheme: dark) block, so the manual toggle reliably overrides the OS preference regardless of stylesheet processing order:

@custom-variant dark (&:where(.dark, .dark *));

:root.dark,
:is(html).dark {
  --bg-primary: oklch(70% 0.2 260);
  --text-on-primary: oklch(10% 0.02 260);
  --bg-surface: oklch(15% 0.01 260);
  --text-on-surface: oklch(90% 0.005 260);
  --bg-accent: oklch(75% 0.18 330);
  --border-color: oklch(30% 0.02 260);
  --text-muted: oklch(60% 0.01 260);
}

This gives readers both options. The prefers-color-scheme query handles automatic detection, while the .dark class supports explicit toggling. Both can coexist in the same stylesheet. The :is(html).dark selector has higher specificity than the bare :root inside the media query, ensuring explicit toggles always win.

Adding Multiple Brand Themes

Theme Scoping via Data Attributes

The pattern for brand themes is straightforward: use a data attribute on the root element (e.g., [data-theme="ocean"]) and reassign the same set of custom properties inside that selector. Because every utility class points to the same variable names, the markup never changes.

Implementing Two Additional Themes

The dark+theme compound selectors below cover all common DOM patterns: when both .dark and data-theme are on the same <html> element, when data-theme is on a descendant of .dark, and when .dark is on a descendant of [data-theme].

[data-theme="ocean"] {
  --bg-primary: oklch(50% 0.2 230);
  --text-on-primary: oklch(100% 0 0);
  --bg-surface: oklch(97% 0.01 230);
  --text-on-surface: oklch(20% 0.02 230);
  --bg-accent: oklch(60% 0.18 180);
  --border-color: oklch(82% 0.01 230);
  --text-muted: oklch(50% 0.01 230);
}

:is(html).dark[data-theme="ocean"],
:is(html).dark [data-theme="ocean"],
[data-theme="ocean"] .dark,
[data-theme="ocean"].dark {
  --bg-primary: oklch(65% 0.18 230);
  --text-on-primary: oklch(10% 0.02 230);
  --bg-surface: oklch(12% 0.015 230);
  --text-on-surface: oklch(90% 0.005 230);
  --bg-accent: oklch(70% 0.15 180);
  --border-color: oklch(28% 0.02 230);
  --text-muted: oklch(58% 0.01 230);
}

[data-theme="sunset"] {
  --bg-primary: oklch(60% 0.22 30);
  --text-on-primary: oklch(100% 0 0);
  --bg-surface: oklch(97% 0.008 30);
  --text-on-surface: oklch(22% 0.02 30);
  --bg-accent: oklch(70% 0.2 60);
  --border-color: oklch(83% 0.01 30);
  --text-muted: oklch(52% 0.01 30);
}

:is(html).dark[data-theme="sunset"],
:is(html).dark [data-theme="sunset"],
[data-theme="sunset"] .dark,
[data-theme="sunset"].dark {
  --bg-primary: oklch(68% 0.18 30);
  --text-on-primary: oklch(12% 0.02 30);
  --bg-surface: oklch(14% 0.012 30);
  --text-on-surface: oklch(88% 0.005 30);
  --bg-accent: oklch(72% 0.16 60);
  --border-color: oklch(30% 0.02 30);
  --text-muted: oklch(56% 0.01 30);
}

Adding a new brand theme is just another block of variable assignments. The markup utilities remain untouched.

Switching Themes at Runtime

A minimal JavaScript snippet handles both theme and dark mode toggling. The setTheme function validates input against a known allowlist to prevent arbitrary strings from being written to the DOM attribute. The dark mode toggle persists the user's preference to localStorage and restores it on page load:

const ALLOWED_THEMES = new Set(["default", "ocean", "sunset"]);

function setTheme(theme) {
  if (!ALLOWED_THEMES.has(theme)) {
    console.warn(`setTheme: unknown theme "${theme}" ignored`);
    return;
  }
  document.documentElement.dataset.theme = theme;
}

(function restorePreferences() {
  const saved = localStorage.getItem("colorScheme");
  if (saved === "dark") document.documentElement.classList.add("dark");
  if (saved === "light") document.documentElement.classList.remove("dark");
  // if null: OS preference governs via @media
})();

function toggleDarkMode() {
  const isDark = document.documentElement.classList.toggle("dark");
  localStorage.setItem("colorScheme", isDark ? "dark" : "light");
}

function clearDarkModePreference() {
  localStorage.removeItem("colorScheme");
  document.documentElement.classList.remove("dark");
}

Calling setTheme('ocean') or toggleDarkMode() is all it takes for every themed element on the page to update. The clearDarkModePreference() function removes the stored preference and returns control to the OS-level prefers-color-scheme media query.

Building an Adaptive Card Component

Markup Using Only Semantic Utilities

Here is a card component with a heading, body text, and a CTA button, built with nothing but semantic utility classes. To use it, place this markup inside an HTML document that links to your compiled CSS output (e.g., <link rel="stylesheet" href="dist/out.css">):

<div class="bg-surface border border-stroke rounded-2xl p-6 shadow-md max-w-sm">
  <h2 class="text-on-surface text-xl font-semibold mb-2">Card Title</h2>
  <p class="text-muted text-sm mb-4">
    This card adapts to any theme and color scheme automatically, using only semantic utility classes.
  </p>
  <button class="bg-primary text-on-primary px-4 py-2 rounded-lg font-medium hover:opacity-90 transition-opacity">
    Action
  </button>
</div>

There are no dark: prefixes. No theme-specific classes. The same markup renders correctly across the default theme, ocean, sunset, and any future theme, in both light and dark modes.

Visual Result Across Themes

Readers can verify this by pasting the complete CSS and HTML into a StackBlitz Vite project (with tailwindcss and @tailwindcss/vite installed). Standard CodePen Tailwind support targets v3 and will not process v4 directives. Toggling data-theme between ocean and sunset on the <html> element and adding or removing the dark class should produce distinctly different palettes from identical markup.

The Complete Theme CSS File

The following is the entire CSS file assembled from the preceding sections. It can be dropped into any Tailwind v4 project as a single, self-contained theming system:

/* === Entry Point === */
@import "tailwindcss";

/* === Manual Dark Mode Support === */
@custom-variant dark (&:where(.dark, .dark *));

/* === Semantic Token Registration === */
@theme inline {
  --color-primary:    var(--bg-primary,    oklch(55% 0.25 260));
  --color-on-primary: var(--text-on-primary, oklch(100% 0 0));
  --color-surface:    var(--bg-surface,    oklch(98% 0.005 260));
  --color-on-surface: var(--text-on-surface, oklch(20% 0.02 260));
  --color-accent:     var(--bg-accent,     oklch(65% 0.2 330));
  --color-stroke:     var(--border-color,  oklch(85% 0.01 260));
  --color-muted:      var(--text-muted,    oklch(55% 0.01 260));
}

/* === Default Theme (Light) === */
:root {
  --bg-primary: oklch(55% 0.25 260);
  --text-on-primary: oklch(100% 0 0);
  --bg-surface: oklch(98% 0.005 260);
  --text-on-surface: oklch(20% 0.02 260);
  --bg-accent: oklch(65% 0.2 330);
  --border-color: oklch(85% 0.01 260);
  --text-muted: oklch(55% 0.01 260);
}

/* === Default Theme (Auto Dark) === */
@media (prefers-color-scheme: dark) {
  :root {
    --bg-primary: oklch(70% 0.2 260);
    --text-on-primary: oklch(10% 0.02 260);
    --bg-surface: oklch(15% 0.01 260);
    --text-on-surface: oklch(90% 0.005 260);
    --bg-accent: oklch(75% 0.18 330);
    --border-color: oklch(30% 0.02 260);
    --text-muted: oklch(60% 0.01 260);
  }
}

/* === Default Theme (Manual Dark Toggle) === */
/* Uses :root.dark / :is(html).dark for higher specificity than the
   bare :root inside @media (prefers-color-scheme: dark), ensuring
   the manual toggle always overrides the OS preference. */
:root.dark,
:is(html).dark {
  --bg-primary: oklch(70% 0.2 260);
  --text-on-primary: oklch(10% 0.02 260);
  --bg-surface: oklch(15% 0.01 260);
  --text-on-surface: oklch(90% 0.005 260);
  --bg-accent: oklch(75% 0.18 330);
  --border-color: oklch(30% 0.02 260);
  --text-muted: oklch(60% 0.01 260);
}

/* === Ocean Theme (Light + Dark) === */
[data-theme="ocean"] {
  --bg-primary: oklch(50% 0.2 230);
  --text-on-primary: oklch(100% 0 0);
  --bg-surface: oklch(97% 0.01 230);
  --text-on-surface: oklch(20% 0.02 230);
  --bg-accent: oklch(60% 0.18 180);
  --border-color: oklch(82% 0.01 230);
  --text-muted: oklch(50% 0.01 230);
}

:is(html).dark[data-theme="ocean"],
:is(html).dark [data-theme="ocean"],
[data-theme="ocean"] .dark,
[data-theme="ocean"].dark {
  --bg-primary: oklch(65% 0.18 230);
  --text-on-primary: oklch(10% 0.02 230);
  --bg-surface: oklch(12% 0.015 230);
  --text-on-surface: oklch(90% 0.005 230);
  --bg-accent: oklch(70% 0.15 180);
  --border-color: oklch(28% 0.02 230);
  --text-muted: oklch(58% 0.01 230);
}

/* === Sunset Theme (Light + Dark) === */
[data-theme="sunset"] {
  --bg-primary: oklch(60% 0.22 30);
  --text-on-primary: oklch(100% 0 0);
  --bg-surface: oklch(97% 0.008 30);
  --text-on-surface: oklch(22% 0.02 30);
  --bg-accent: oklch(70% 0.2 60);
  --border-color: oklch(83% 0.01 30);
  --text-muted: oklch(52% 0.01 30);
}

:is(html).dark[data-theme="sunset"],
:is(html).dark [data-theme="sunset"],
[data-theme="sunset"] .dark,
[data-theme="sunset"].dark {
  --bg-primary: oklch(68% 0.18 30);
  --text-on-primary: oklch(12% 0.02 30);
  --bg-surface: oklch(14% 0.012 30);
  --text-on-surface: oklch(88% 0.005 30);
  --bg-accent: oklch(72% 0.16 60);
  --border-color: oklch(30% 0.02 30);
  --text-muted: oklch(56% 0.01 30);
}

Drop this file into a project, point the build tool at it, and the entire multi-theme system is operational.

Tips, Gotchas, and Best Practices

Token Naming Conventions

Keep token names role-based (primary, surface, muted) rather than appearance-based (blue, light-bg). Appearance-based names defeat the purpose of the indirection layer and will cause confusion the moment a theme uses a different hue for that role.

Opacity Modifiers

Note: Opacity modifiers like bg-primary/50 work with these tokens. In my test with Tailwind v4.3.3, the utility compiled to color-mix(in oklab, var(--bg-primary, ...) 50%, transparent), with a plain var() fallback for browsers without color-mix() support. Older v4 releases may behave differently, so check your compiled output.

Browsers without color-mix() support fall back to the full-opacity color. If you need to support them, define an explicit variant as a separate token (e.g., --color-primary-muted) or apply the opacity CSS property directly on the element.

Performance Considerations

The browser's style engine resolves CSS custom properties natively, with no additional JavaScript execution and no measurable rendering overhead in typical page loads. This approach requires no runtime color computation, no style recalculation libraries, and no framework-specific context providers. The browser does all the work.

Avoiding Conflicts with Tailwind's Built-in Palette

Registering a token named --color-red-500 inside @theme inline will clash with Tailwind's default color scale, producing unpredictable results. Semantic, role-based names like primary and accent reduce this risk because they occupy a different namespace from the built-in palette. However, always verify your chosen names do not collide by inspecting the compiled output with npx tailwindcss -i src/app.css -o out.css and searching for your token names.

Next Steps

Semantic tokens via @theme inline combined with CSS custom properties support unlimited themes and toggle dark mode automatically, with markup free of theme-specific or mode-specific classes regardless of how many themes exist. From here, the same pattern extends naturally to spacing and border-radius tokens, integrates well with CMS-driven theme pickers, and scales to design system libraries. Try adding --spacing-* tokens using the same @theme inline pattern, then consult the full @theme API in the Tailwind v4 documentation for the remaining capabilities.

SitePoint TeamSitePoint Team

Sharing our passion for building incredible internet things.