Dark mode

How it works

defineConfig wires TailwindPlugin, which generates two CSS layers. The static @theme layer is consumed by Tailwind at build time. The dynamic @layer theme layer scopes variables that can be refreshed at runtime on the web by ThemeProvider:

css
/* Static — Tailwind build-time values */
@theme {
  --color-primary: #6750a4;
  --color-surface: #fffbfe;
}

/* Dynamic — scoped to .dynamic; ThemeProvider can refresh it on the web */
@layer theme {
  .dynamic {
    --color-primary: #6750a4; /* light */
    --color-surface: #fffbfe;
  }
}

@layer theme {
  /* Generated for darkMode: 'class', darkSelector: '.dark' */
  .dark .dynamic,
  .dark.dynamic {
    --color-primary: #d0bcff; /* dark */
    --color-surface: #1c1b1f;
  }
}

Every element inside .dynamic inherits whichever set of variables is active. For a static theme, switching dark mode is only a CSS class or media-query change; no JavaScript theme recalculation is required.

Configuration

darkMode: 'class' (default)

Dark mode activates when a CSS class is present on an ancestor element.

ts
// theme.config.ts
import { defineConfig } from '@udixio/tailwind';

export default defineConfig({
  sourceColor: '#6750A4',
  darkMode: 'class',
  darkSelector: '.dark', // default value
});

Add the class to <html> or <body>:

html
<html class="dark"></html>
<!-- dark mode on -->
<html></html>
<!-- light mode -->

darkMode: 'media'

Dark mode follows the system preference automatically — no class management needed.

ts
// theme.config.ts
import { defineConfig } from '@udixio/tailwind';

export default defineConfig({
  sourceColor: '#6750A4',
  darkMode: 'media',
});
css
/* Generated */
@media (prefers-color-scheme: dark) {
  .dynamic {
    --color-primary: #d0bcff;
  }
}

Dynamic theme updates on the web

The generated CSS is enough for a fixed light/dark theme. In an app that also supports runtime changes to sourceColor, variant, palettes or other configuration, mount ThemeProvider with the shared config. The provider from @udixio/ui-react, @udixio/ui-angular or @udixio/ui-svelte injects the dynamic layer and regenerates it when the config changes:

tsx
import { useState, useEffect } from 'react';
import themeConfig from './theme.config';
import { ThemeProvider } from '@udixio/ui-react';

export function App() {
  const [isDark, setIsDark] = useState(false);

  useEffect(() => {
    document.documentElement.classList.toggle('dark', isDark);
  }, [isDark]);

  return (
    <>
      <ThemeProvider config={themeConfig} />
      <button onClick={() => setIsDark((d) => !d)}>Toggle dark mode</button>
    </>
  );
}

ThemeProvider itself does not manage the dark class — it only generates CSS for both modes. You control when dark mode is active by toggling the selector class.

Runtime API note

isDark is a runtime context value used by the headless loader() API and advanced callbacks. It is not a defineConfig option in the Tailwind contract: defineConfig generates both schemes, while darkMode and darkSelector control which one is active in the page.

Custom darkSelector

Any valid CSS selector works:

ts
darkSelector: '[data-theme="dark"]';
html
<html data-theme="dark"></html>

The .dynamic selector

.dynamic is the scope boundary for live theme updates. Elements outside it still receive Tailwind theme values from the static @theme layer — but those don’t update when the user changes the source color interactively.

For a standard app, put .dynamic on <body>:

html
<body class="dynamic dark bg-surface text-on-surface"></body>

For isolated theming in a specific component, apply it to that element only:

html
<div class="dynamic theme-ocean rounded-xl p-4">
  <!-- only this section uses the ocean theme variables -->
</div>