---
title: "Theming"
description: "Modern and performant theme system for Maz-UI built on native CSS features (light-dark(), color-scheme, color-mix(in oklch)) for v5."
package: "@maz-ui/themes"
url: "https://maz-ui.com/ecosystem/themes"
---

# Theming

> Modern and performant theme system for Maz-UI built on native CSS features (light-dark(), color-scheme, color-mix(in oklch)) for v5.

- Package: `@maz-ui/themes`
- Import: `import { definePreset, useTheme } from '@maz-ui/themes'` or `import { mazUi } from '@maz-ui/themes/presets/mazUi'`
- Web: https://maz-ui.com/ecosystem/themes

Modern and performant theme system for Maz-UI built on native CSS features (`light-dark()`, `color-scheme`, `color-mix(in oklch)`) for v5.

## Features

- **Native `light-dark()` + `color-scheme`** - Modern CSS theming with zero JS overhead for the light/dark switch
- **Native widget theming** - `color-scheme` makes scrollbars, native `<select>`, date pickers and autofill follow the active mode automatically
- **Animated theme switch** - Optional full-page View Transitions via `setColorMode(..., { animate: true })`
- **Anti-FART** - `<meta name="color-scheme">` injected at boot to prevent any Flash of inAccurate coloR Theme (no setup needed)
- **Automatic color scales** - the `-50` to `-950` steps are derived via OKLCh relative color syntax, with an opt-in `perceptual` mode that keeps their order and hue with any base color
- **Performance Strategies** - Runtime injection or build-time generation according to your needs
- **Strict TypeScript** - Complete types for perfect DX
- **Zero FOUC** - Pass the preset object up front; the full CSS is injected synchronously on first paint
- **Flexible Presets** - Ready-to-use and customizable configurations

## Theme Editor

Create your own theme with the Theme Editor.

## Quick Usage

### 1. Plugin Configuration with `MazUi` plugin

To avoid FOUC (Flash of Unstyled Content), **you should provide the theme preset**.

```typescript
// main.ts
import { MazUi } from 'maz-ui/plugins/maz-ui'
import { mazUi } from '@maz-ui/themes/presets/mazUi'
import { createApp } from 'vue'

const app = createApp(App)

app.use(MazUi, {
  theme: {
    preset: mazUi, // pristine | ocean | obsidian | nova | folio
    strategy: 'runtime', // 'runtime' | 'buildtime'
    darkModeStrategy: 'class', // 'class' | 'media' (only if mode is `both`)
    mode: 'both', // 'light' | 'dark' | 'both' (supported color modes)
    colorMode: 'auto', // 'auto' | 'light' | 'dark' (initial color mode, only if mode is 'both')
    // Class added to <html> when dark mode is forced (default: 'dark')
    darkClass: 'dark',
    // Class added to <html> when light mode is forced (default: 'light')
    lightClass: 'light',
    persistPreset: true, // remember the active preset name across reloads
  }
})
```

**Tip: Alternative: MazUiProvider**

You can also initialize the theme via the [`MazUiProvider`](https://maz-ui.com/guide/maz-ui-provider.md) component for lazy-loaded pages or subtree-scoped themes.

### 2. Setup your CSS to support theme foundation and dark mode

```css
html {
  font-size: var(--maz-base-font-size);
  font-family: var(--maz-font-family);
  background-color: var(--maz-surface);
  color: var(--maz-foreground);
}
```

### 3. Using in your components

```vue
<script setup>
import { useTheme } from '@maz-ui/themes'

const { toggleDarkMode, isDark, updateTheme } = useTheme()
</script>

<template>
  <div class="bg-surface text-foreground">
    <button
      class="rounded-md bg-primary text-primary-foreground"
      @click="toggleDarkMode"
    >
      {{ isDark ? '☀️' : '🌙' }} Toggle theme
    </button>
  </div>
</template>
```

## Configuration

- `preset`: The theme preset to use
- `overrides` (optional): Override specific parts of the theme
- `strategy` (optional, default `'runtime'`): The rendering strategy to use - `'runtime' | 'buildtime'`
- `darkModeStrategy` (optional, default `'class'`): The dark mode strategy - `'class' | 'media'`, only if you use mode `both`
- `mode` (optional, default `'both'`): The supported color modes - `'light' | 'dark' | 'both'`
- `colorMode` (optional, default `'auto'`): The initial color mode - `'light' | 'dark' | 'auto'` (only if mode is `'both'`)
- `darkClass` (optional, default `'dark'`): Class added to `<html>` when `colorMode === 'dark'`
- `lightClass` (optional, default `'light'`): Class added to `<html>` when `colorMode === 'light'` - mirror of `darkClass`
- `persistPreset` (optional, default `true`): Persist the active preset name in the `maz-preset` cookie so it is restored on reload.

### Persistence of the user preferences

`@maz-ui/themes` stores the preferences of the user in two cookies (1-year TTL, `SameSite=Lax`), so the server can render them:

| Cookie | Value | Example |
| --- | --- | --- |
| `maz-color-mode` | `<choice>:<system>`, each part optional: the color mode chosen with `setColorMode()` and the last system preference seen by the browser | `dark`, `auto:light`, `:dark` |
| `maz-preset` | `<configured>:<active>`: the preset declared in `options.preset` and the one applied with `updateTheme()` | `folio:ocean` |

- The configured preset always wins over a foreign cookie: a switch is only restored when the `configured` part matches `options.preset`, so a cookie written by another app on the same host (or by a previous config) is ignored. A switch to a preset that no longer exists falls back to the configured one.
- The cookies are only written when their value changes.
- **Reactive**: in the browsers that support the [Cookie Store API](https://developer.mozilla.org/docs/Web/API/Cookie_Store_API), a change of a cookie (another tab, the server, the devtools) is applied without a reload, and a deleted cookie restores the config. The other browsers keep the values read at boot.

Set `persistPreset: false` to never read nor write the `maz-preset` cookie:

```typescript
app.use(MazUi, {
  theme: {
    preset: mazUi,
    persistPreset: false,
  },
})
```

Useful when the active preset is fully driven by the app and no end-user switching is exposed.

## Color modes and class toggling

`darkClass` and `lightClass` interact with `darkModeStrategy` to control how `<html>` is decorated.

### `darkModeStrategy: 'class'` (default)

- `setColorMode('dark')` adds `darkClass` (default `.dark`) to `<html>` → `color-scheme: only dark`.
- `setColorMode('light')` adds `lightClass` (default `.light`) to `<html>` → `color-scheme: only light`.
- `setColorMode('auto')` adds the class of the system preference, and follows its changes.

Forcing the class on the root ensures native widgets (scrollbars, native `<select>`, date pickers, autofill backgrounds, …) match the color mode of the page.

A host that toggles the classes itself (VitePress, a Tailwind toggle, a blocking script) stays in charge: at boot, a class already on `<html>` is kept and becomes the state of `useTheme()`, and a later change of the class updates the state. A host that only toggles `darkClass` gets `lightClass` added back in light mode.

### `darkModeStrategy: 'media'`

- No class is ever added to `<html>`.
- The browser always follows `prefers-color-scheme` via `color-scheme: light dark`.
- `setColorMode()` still updates the persisted cookie but does **not** force a visual override.

## Server rendering (SSR and SSG)

`@maz-ui/nuxt` does it for you. With another server, render the theme of the user in the `<head>` so the first paint has it, then install the plugin as usual:

```typescript
import { getRequestThemeContext, getThemeHead, renderThemeHead } from '@maz-ui/themes'

export async function renderHead(request: Request) {
  const head = await getThemeHead(themeOptions, getRequestThemeContext({
    'cookie': request.headers.get('cookie') ?? undefined,
    'sec-ch-prefers-color-scheme': request.headers.get('sec-ch-prefers-color-scheme') ?? undefined,
  }))

  return {
    htmlClass: head.htmlClass,
    head: renderThemeHead(head),
  }
}
```

- `getThemeHead(options, context)` resolves the preset and the color mode of the user from the cookies and the `Sec-CH-Prefers-Color-Scheme` client hint. It returns the class of `<html>` (undefined when the server cannot know it), the CSS of the preset, the `color-scheme` meta and a blocking script that applies the live system preference before the first paint.
- `renderThemeHead(head)` renders these tags as HTML. `getThemeHeadTags(head)` returns them in the format of unhead (`useHead`).
- `resolveThemeContext(options)` returns the same context without the tags, in the browser or on the server.
- `options.preset` accepts the name of a built-in preset or a preset object. A name loads the preset on demand: pass the object to the client plugin to avoid a flash before it is loaded.

## Animated theme switch

For a full-page animated swap (rather than per-variable color tweens), pass `{ animate: true }` to `setColorMode` or `toggleDarkMode`:

```vue
<script setup>
import { useTheme } from '@maz-ui/themes'

const { toggleDarkMode, setColorMode } = useTheme()

async function onToggle() {
  await toggleDarkMode({ animate: true })
}

async function forceDark() {
  await setColorMode('dark', { animate: true })
}
</script>
```

- Wraps the switch in `document.startViewTransition()` so the browser captures a snapshot of the page before/after and crossfades between them.
- The View Transition glue is **lazy-imported** - when `animate` is not used, it stays out of the boot bundle.
- **Graceful degradation:** in browsers that don't support the API, the change is applied immediately with no animation (no error thrown).

## Interactive Demo

**Real-time theme control**

```vue
<script setup>
import { useTheme } from '@maz-ui/themes'

const { presetName, updateTheme, colorMode, setColorMode } = useTheme()
</script>

<template>
  <div class="demo-theme-controls">
    <div class="space-y-4">
      <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
        <MazBtn color="primary">Primary Button</MazBtn>
        <MazBtn color="secondary">Secondary Button</MazBtn>
        <MazBtn color="success">Success Button</MazBtn>
        <MazBtn color="warning">Warning Button</MazBtn>
      </div>
      <div class="theme-controls space-y-4">
        <div class="flex items-center gap-4">
          <label class="text-sm font-medium">Mode:</label>
          <MazBtn
            size="sm"
            :color="colorMode === 'light' ? 'primary' : 'secondary'"
            @click="setColorMode('light')"
          >
            ☀️ Light
          </MazBtn>
          <MazBtn
            size="sm"
            :color="colorMode === 'dark' ? 'primary' : 'secondary'"
            @click="setColorMode('dark')"
          >
            🌙 Dark
          </MazBtn>
          <MazBtn
            size="sm"
            :color="colorMode === 'auto' ? 'primary' : 'secondary'"
            @click="setColorMode('auto')"
          >
            🔄 Auto
          </MazBtn>
        </div>
        <div class="space-y-2">
          <label class="text-sm font-medium">Preset:</label>
          <div class="flex gap-2">
            <MazBtn
              size="sm"
              :color="presetName === 'maz-ui' ? 'primary' : 'secondary'"
              @click="updateTheme('mazUi')"
            >
              Maz-UI
            </MazBtn>
            <MazBtn
              size="sm"
              :color="presetName === 'ocean' ? 'primary' : 'secondary'"
              @click="updateTheme('ocean')"
            >
              Ocean
            </MazBtn>
            <MazBtn
              size="sm"
              :color="presetName === 'pristine' ? 'primary' : 'secondary'"
              @click="updateTheme('pristine')"
            >
              Pristine
            </MazBtn>
            <MazBtn
              size="sm"
              :color="presetName === 'obsidian' ? 'primary' : 'secondary'"
              @click="updateTheme('obsidian')"
            >
              Obsidian
            </MazBtn>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>
```

## Available Presets

### Maz-UI (Default)

The default theme inspired by Maz-UI identity with vibrant and modern colors.

```typescript
import { mazUi } from '@maz-ui/themes/presets'
```

### Pristine

A minimalist and elegant theme inspired by Apple design with pure black and white aesthetics.

```typescript
import { pristine } from '@maz-ui/themes/presets'
```

### Ocean

A vibrant ocean-inspired theme with deep blues, aquatic greens, and coral accents. Features rounded borders and Poppins font for a fresh, modern look.

```typescript
import { ocean } from '@maz-ui/themes/presets'
```

### Obsidian

A dark and elegant theme with a focus on readability and minimalism.

```typescript
import { obsidian } from '@maz-ui/themes/presets'
```

### Nova

A modern startup / AI / creative preset - electric violet primary, hot coral secondary, cyan accent. Tight 0.5rem `md` radius, Geist + Inter font stack, snappy ease-out spring. Built for product / AI surfaces (think Linear, Vercel, OpenAI energy) where the UI itself should feel alive.

```typescript
import { nova } from '@maz-ui/themes/presets'
```

### Folio

An electric and modern preset made for long reading, and the one of this documentation: an electric blue primary, an electric magenta secondary and a cyan accent that blend into a plasma gradient, on faintly violet surfaces (almost white in light mode, violet-black in dark mode). Fraunces (serif) for the headings, Inter for the text, Geist Mono for the code, soft 0.625rem `md` radius. In dark mode the color roles turn into bright tones carrying dark text, so they stay readable as text on the surface too. Every text pair reaches WCAG AA.

```typescript
import { folio } from '@maz-ui/themes/presets'
```

**Tip: Fonts**

Like the other presets, Folio only declares font stacks: load Fraunces, Inter and Geist Mono yourself (self-hosted or from a font service), the stacks fall back to system fonts otherwise.

## Rendering Strategies

### ⚡ Runtime (Recommended)

CSS is generated and injected synchronously on first paint. Pass the preset object directly (`preset: mazUi`) so the full stylesheet is rendered before the first frame - no FOUC.

```typescript
import { mazUi } from '@maz-ui/themes/presets'

app.use(MazUi, {
  theme: {
    preset: mazUi,
    strategy: 'runtime'
  }
})
```

### 🏗️ Buildtime

CSS generated at build-time and included in the bundle.
You have to build your CSS files before running your app.

See Build-time for more details.

```typescript
import { mazUi } from '@maz-ui/themes/presets'

app.use(MazUi, {
  theme: {
    preset: mazUi,
    strategy: 'buildtime'
  }
})
```

## Creating Custom Themes

### Basic Theme

**Create your own theme**

```typescript
import { definePreset, MazUiTheme, type ThemePresetOverrides, ocean } from '@maz-ui/themes'

const customTheme = await definePreset({
  base: 'ocean',
  overrides: {
    name: 'custom-purple',
    foundation: {
      'font-family': 'Inter, sans-serif'
    },
    scales: {
      rounded: { md: '1rem' }
    },
    colors: {
      light: {
        primary: 'oklch(0.62 0.24 305)',
        secondary: 'oklch(0.94 0.04 320)',
        accent: 'oklch(0.7 0.22 290)'
      },
      dark: {
        primary: 'oklch(0.72 0.22 305)',
        secondary: 'oklch(0.28 0.06 320)',
        accent: 'oklch(0.78 0.2 290)'
      }
    }
  },
})
/**
 * or with preset object
 * The function is synchronous when using a preset object
 */
const customTheme = definePreset({
  base: ocean,
  overrides,
})

// Usage

// with plugin
app.use(MazUiTheme, {
  preset: customTheme,
})

// or with composable
const { updateTheme } = useTheme()
updateTheme(customTheme)
```

### Advanced Theme with Overrides

```typescript
import { definePreset } from '@maz-ui/themes'

const brandTheme = await definePreset({
  base: 'maz-ui',
  overrides: {
    name: 'brand',
    foundation: {
      'border-width': '2px',
      'font-family': 'Inter, sans-serif'
    },
    scales: {
      rounded: { md: '0.75rem' }
    },
    colors: {
      light: {
        primary: 'oklch(0.6 0.18 254)',
        secondary: 'oklch(0.96 0.01 254)',
        surface: 'oklch(0.99 0.005 254)',
        accent: 'oklch(0.7 0.22 305)'
      },
      dark: {
        primary: 'oklch(0.7 0.18 254)',
        secondary: 'oklch(0.22 0.03 254)',
        surface: 'oklch(0.18 0.02 254)',
        accent: 'oklch(0.78 0.2 305)'
      }
    }
  }
})
```

### Customizing scales

Each `ThemeScales` entry maps to a Tailwind utility - override the keys you care about and the rest stays untouched:

```typescript
const denseTheme = definePreset({
  base: 'maz-ui',
  overrides: {
    name: 'dense',
    foundation: {
      // Tighter spacing - every p-N / gap-N / m-N rescales
      space: '0.2rem',
    },
    scales: {
      // Sharper corners on the whole radius scale
      rounded: {
        'xs': '0.0625rem',
        'sm': '0.125rem',
        'md': '0.375rem',
        'lg': '0.5rem',
        'xl': '0.75rem',
        '2xl': '1rem',
        '3xl': '1.5rem',
      },
      // Custom elevated-surface shadow
      shadow: {
        elevation: '0 8px 24px rgba(0, 0, 0, 0.15)',
      },
    },
  },
})
```

Foundation gets new optional keys for fonts and disabled-state behaviour:

```typescript
const codeTheme = definePreset({
  base: 'maz-ui',
  overrides: {
    foundation: {
      'font-mono-stack': '"JetBrains Mono", ui-monospace, SFMono-Regular, monospace',
      'font-display-stack': '"Cal Sans", Manrope, sans-serif',
      'disabled-opacity': '0.4',
      'disabled-cursor': 'wait',
    },
  },
})
```

### Color scales

The steps `-50` to `-950` of the brand colors (`primary`, `secondary`, `accent`, `info`, `success`, `warning`, `destructive`) are derived from the base color, which is the `500` step. `scales.color` picks the derivation:

- `relative` (default): a fixed lightness offset per step. With a very dark or very light base, the scale collapses: half of a navy scale is black, half of a gold scale is white.
- `perceptual`: each step travels a share of the way between the base and a light or a dark bound, with a chroma capped near white and black. The steps keep their order and their hue whatever the base, and the classes do not change (`bg-accent-300`, `text-primary-700`...).

```typescript
const brandTheme = definePreset({
  base: 'maz-ui',
  overrides: {
    colors: {
      light: { secondary: '#0E1450', accent: '#FFC93C' },
    },
    scales: {
      color: 'perceptual',
    },
  },
})
```

- The scales stay dynamic: they follow `updateTheme()` and the light and dark values of the base. A step is relative to the base of each mode: `accent-300` gives two colors in light and dark when the two bases differ.
- `surface`, `foreground`, `contrast` and `divider` keep the relative scale: the components use it as "one notch darker or lighter" (`bg-surface-600`).
- The steps are redefined for the maz-ui components (`--maz-color-<role>-<step>`) and for the Tailwind classes of your project (`--color-<role>-<step>`). They take precedence over a step you redefine in your own `@theme`.
- The browsers clip an out-of-gamut color channel by channel, which can shift its hue: the chroma cap keeps this shift under the visible threshold for almost every color, the most saturated blues and yellows excepted.

### Per-component overrides

`components` is a small, validated set of per-component knobs. Every entry is per-mode where appropriate so light and dark can drift independently:

```typescript
const surfaceTheme = definePreset({
  base: 'maz-ui',
  overrides: {
    name: 'cool-surfaces',
    components: {
      btn: {
        // Bias every button text heavier
        'font-weight': '600',
      },
      container: {
        // Cards / dialogs / popovers / drawers - light = page surface, dark = a tier above
        bg: { light: 'var(--maz-surface)', dark: 'var(--maz-surface-300)' },
      },
      input: {
        // All form-control surfaces
        bg: { light: 'var(--maz-surface-100)', dark: 'var(--maz-surface-400)' },
      },
    },
  },
})
```

Components consume these via `bg-container` / `bg-input` Tailwind utilities, so a single override propagates everywhere - no per-component class hunt.

### Dialog sizing

`components.dialog` controls the width of [`MazDialog`](https://maz-ui.com/components/maz-dialog.md) on tablet and up. The two keys are emitted as `--maz-dialog-max-width` / `--maz-dialog-min-width` and consumed by the component through `var(--maz-dialog-max-width, 38rem)` / `var(--maz-dialog-min-width, 32rem)`:

```typescript
const wideDialogTheme = definePreset({
  base: 'maz-ui',
  overrides: {
    name: 'wide-dialogs',
    components: {
      dialog: {
        'max-width': '48rem',
        'min-width': '36rem',
      },
    },
  },
})
```

Both values are in `rem`, so they scale with the preset's `base-font-size`. Bundled presets anchored on `14px` use `38rem` / `32rem`; `ocean` (`16px`) ships scaled rem values (`33.25rem` / `28rem`) to keep the same rendered pixel width. The `max-width` / `min-width` props on `MazDialog` still override the preset per instance.

## useTheme Composable API

```typescript
const {
  // Reactive state
  preset, // ComputedRef<ThemePreset>
  presetName, // ComputedRef<string>
  colorMode, // Ref<'light' | 'dark' | 'auto'> - also accepts assignment (computed setter)
  isDark, // ComputedRef<boolean>
  strategy, // ComputedRef<'runtime' | 'buildtime'>
  mode, // ComputedRef<'light' | 'dark' | 'both'>
  darkModeStrategy, // ComputedRef<'class' | 'media'>

  // Actions
  updateTheme, // (preset: ThemePreset | ThemePresetName | ThemePresetOverrides) => Promise<void>
  setColorMode, // (mode: 'light' | 'dark' | 'auto', options?: { animate?: boolean }) => Promise<void>
  toggleDarkMode, // (options?: { animate?: boolean }) => Promise<void>
} = useTheme()

// Set the color mode imperatively
await setColorMode('dark')

// Or assign to `colorMode` - it's a computed with a setter
colorMode.value = 'dark'

// Animated full-page switch via View Transitions
await toggleDarkMode({ animate: true })
```

`setColorMode` and `toggleDarkMode` are **async** (`Promise<void>`). The promise resolves once the change - and the optional View Transition - has been applied. Callers can ignore the return value when no transition is needed.

### Advanced Usage Examples

```vue
<script setup>
import { useTheme } from '@maz-ui/themes'
import { computed } from 'vue'

const { presetName, isDark, updateTheme, setColorMode } = useTheme()

// Computed for interface
const themeIcon = computed(() => isDark.value ? '☀️' : '🌙')

// Function to apply a temporary custom theme
function previewColor(color: string) {
  updateTheme({
    colors: {
      light: { primary: color },
      dark: { primary: color }
    }
  })
}

// Auto mode with system detection
function enableAutoMode() {
  setColorMode('auto')
}
</script>
```

## Build-time Strategy: Complete Guide

The build-time strategy allows you to generate your theme CSS files at compile time, offering the best performance in production.

### Step-by-step configuration

#### 1. Generate CSS

```typescript [Vue]
import { mkdirSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import {
  // Build all css in one file
  buildThemeCSS,
  // Build multiple themes with one file each
  generateThemeBundle,
  // Build separate css files
  buildSeparateThemeFiles,
  definePreset,
  mazUi,
} from '@maz-ui/themes'

import App from './App.vue'

const _dirname = dirname(fileURLToPath(import.meta.url))

// Custom theme
const customPreset = definePreset({
  base: mazUi,
  overrides: {
    name: 'custom',
    colors: {
      light: { primary: 'oklch(0.6 0.18 254)' },
      dark: { primary: 'oklch(0.7 0.18 254)' },
    },
  },
})

/**
 * Generate complete CSS
 */
const fullCSS = buildThemeCSS({
  preset: customPreset,
  mode: 'both',
})

writeFileSync(join(_dirname, 'public/custom.css'), fullCSS)

/**
 * Or generate per-mode CSS files
 */
const { full, lightOnly, darkOnly } = buildSeparateThemeFiles(customPreset, {
  darkSelector: 'class',
})

writeFileSync(join(_dirname, 'public/custom-full.css'), full)

/**
 * Or generate bundle for multiple themes in one file each
 */
const themeBundle = generateThemeBundle([customPreset, mazUi], {
  mode: 'both',
})

Object.entries(themeBundle).forEach(([name, css]) => {
  writeFileSync(join(_dirname, `public/${name}.css`), css)
})

const app = createApp(App)

app.use(MazUiTheme, {
  preset: customPreset,
  strategy: 'buildtime', // Important!
  darkModeStrategy: 'class',
})

app.mount('#app')
```

```typescript [Vitepress]
import {
  buildThemeCSS,
  generateThemeBundle,
  buildSeparateThemeFiles,
  createThemeStylesheet,
  definePreset,
  mazUi,
  CSS_ID,
} from '@maz-ui/themes'

// Custom theme
const customPreset = definePreset({
  base: mazUi,
  overrides: {
    name: 'custom',
    colors: {
      light: { primary: 'oklch(0.6 0.18 254)' },
      dark: { primary: 'oklch(0.7 0.18 254)' },
    },
  },
})

// Generate complete CSS
const fullCSS = buildThemeCSS({
  preset: customPreset,
  mode: 'both',
})

// Or generate per-mode CSS files
const { full, lightOnly, darkOnly } = buildSeparateThemeFiles(customPreset, {
  darkSelector: 'class',
})

// Or generate bundle for multiple themes
const themeBundle = generateThemeBundle([customPreset, mazUi], {
  mode: 'both',
})

export default defineConfig<DefaultTheme.Config>({
  head: [
    ['style', { id: CSS_ID, type: 'text/css' }, full],
  ] satisfies HeadConfig[],
})
```

```typescript [Nuxt]
/*
 * You dont need to do anything,
 * The module will handle it for you.
 *
 * Just provide a preset to the module
 */

const customPreset = definePreset({
  base: mazUi,
  overrides: {
    name: 'custom',
    colors: {
      light: { primary: 'oklch(0.6 0.18 254)' },
      dark: { primary: 'oklch(0.7 0.18 254)' },
    },
  },
})

export default defineConfig({
  modules: ['@maz-ui/nuxt'],
  mazUi: {
    theme: {
      preset: customPreset,
      mode: 'both',
      colorMode: 'auto',
    },
  },
})
```

#### 2. Include in your HTML (Only for Vue users)

```html
<link rel="stylesheet" href="/themes/custom-full.css">
```

### Utility functions

#### `buildThemeCSS(options)`

Generates complete CSS for a theme.

```typescript
const css = buildThemeCSS({
  preset: customPreset,
  mode: 'both', // 'light' | 'dark' | 'both'
  darkSelector: 'class', // 'class' | 'media'
})
```

#### `generateThemeBundle(presets, options)`

Generates a bundle containing multiple themes.

```typescript
const bundle = generateThemeBundle([theme1, theme2], {
  mode: 'both',
  darkSelector: 'class',
})
// Result: { 'theme1': 'css...', 'theme2': 'css...' }
```

#### `buildSeparateThemeFiles(preset, options)`

Generates separate files for different use cases.

```typescript
const files = buildSeparateThemeFiles(preset)
// Result: { full, lightOnly, darkOnly }
```

#### `createThemeStylesheet(css, options)`

Creates a `
@reference "../../.vitepress/theme/main.css";
` tag with the provided CSS.

```typescript
const styleTag = createThemeStylesheet(css, {
  id: 'my-theme',
  media: 'screen', // optional
})
```

### Build-time strategy advantages

- **Optimal performance** - No client-side CSS generation
- **Efficient caching** - CSS files cached by CDN
- **Reduced bundle** - Generation utilities excluded from client
- **Full compatibility** - Works even with JavaScript disabled

### Recommended use cases

- Production applications with static themes
- Sites with critical performance requirements
- Projects with multiple predefined themes
- Applications requiring fine control over CSS loading

## Token Reference

Every preset emits the same set of CSS variables on `:root`. With `mode: 'both'`, each base color is a single `light-dark(L, D)` declaration that the browser resolves to the active scheme. Use these names directly in your own CSS - `var(--maz-primary)`, `calc(var(--maz-space) * 4)`, etc.

### Foundation

Single-value design tokens. Set via `foundation.<key>` on the preset.

| Preset key | CSS variable | Default | Notes |
| --- | --- | --- | --- |
| `base-font-size` | `--maz-base-font-size` | `14px` | Anchors `rem` for the whole app. Apply on `html { font-size: var(--maz-base-font-size) }`. |
| `border-width` | `--maz-border-width` | `1px` | Default border for components and the global `*::before/::after` reset. |
| `space` | `--maz-space` | `0.25rem` | Base step for every Tailwind spacing utility (`p-1`, `gap-2`, …). |
| `font-family` | `--maz-font-family` | preset-specific | Sans / body font stack. Bridged to Tailwind `--font-sans`. |
| `font-mono-stack` | `--maz-font-mono-stack` | `ui-monospace, …` | Monospace stack - `MazInputCode`, `<code>`, `<kbd>`. Bridged to `--font-mono`. |
| `font-display-stack` | `--maz-font-display-stack` | same as `font-family` | Display / heading stack. Bridged to `--font-display`. |
| `motion-fast` | `--maz-motion-fast` | `100ms` | Bridged to Tailwind `--duration-fast`. |
| `motion-normal` | `--maz-motion-normal` | `200ms` | Default transition duration (`--default-transition-duration`). |
| `motion-slow` | `--maz-motion-slow` | `300ms` | Bridged to `--duration-slow`. |
| `easing-out` | `--maz-easing-out` | `cubic-bezier(0.4, 0, 0.2, 1)` | Default easing (`--ease-out`, also `--default-transition-timing-function`). |
| `easing-in` | `--maz-easing-in` | `cubic-bezier(0.4, 0, 1, 1)` | Bridged to `--ease-in`. |
| `easing-in-out` | `--maz-easing-in-out` | `cubic-bezier(0.4, 0, 0.2, 1)` | Bridged to `--ease-in-out`. |
| `disabled-opacity` | `--maz-disabled-opacity` | `0.5` | Applied to disabled buttons / inputs. |
| `disabled-cursor` | `--maz-disabled-cursor` | `not-allowed` | Cursor for disabled interactive elements. |

> **Why `font-mono-stack` / `font-display-stack` / `motion-*`?** Under Tailwind v4 `prefix(maz)`, the bridge LHS gets prefixed (`--font-mono` → `--maz-font-mono`), so a same-named source token would self-cycle. The `-stack` and `motion-` suffixes break the collision.

### Colors

Defined per mode under `colors.light` / `colors.dark`. Each base color also gets an automatic 50-950 scale.

| Preset key | CSS variable (base) | Auto scale | Notes |
| --- | --- | --- | --- |
| `surface` | `--maz-surface` | `--maz-surface-50..900` | Page / container background. |
| `foreground` | `--maz-foreground` | `--maz-foreground-50..900` | Default text color. |
| `primary` | `--maz-primary` | `--maz-primary-50..900` | Brand color. |
| `primary-foreground` | `--maz-primary-foreground` | - | Text on `bg-primary`. |
| `secondary` | `--maz-secondary` | `--maz-secondary-50..900` | |
| `secondary-foreground` | `--maz-secondary-foreground` | - | |
| `accent` | `--maz-accent` | `--maz-accent-50..900` | |
| `accent-foreground` | `--maz-accent-foreground` | - | |
| `info` | `--maz-info` | `--maz-info-50..900` | Status - informational. |
| `info-foreground` | `--maz-info-foreground` | - | |
| `success` | `--maz-success` | `--maz-success-50..900` | Status - success. |
| `success-foreground` | `--maz-success-foreground` | - | |
| `warning` | `--maz-warning` | `--maz-warning-50..900` | Status - warning. |
| `warning-foreground` | `--maz-warning-foreground` | - | |
| `destructive` | `--maz-destructive` | `--maz-destructive-50..900` | Status - error / danger. |
| `destructive-foreground` | `--maz-destructive-foreground` | - | |
| `contrast` | `--maz-contrast` | `--maz-contrast-50..900` | High-contrast accent (light = near-black, dark = near-white). |
| `contrast-foreground` | `--maz-contrast-foreground` | - | |
| `divider` | `--maz-divider` | `--maz-divider-50..900` | Default border / separator color. |
| `muted` | `--maz-muted` | `--maz-muted-50..900` | Muted / secondary text. |
| `overlay` | `--maz-overlay` | `--maz-overlay-50..900` | Backdrop / dialog scrim. |
| `shadow` | `--maz-shadow` | `--maz-shadow-50..900` | Tint used by Tailwind `shadow-*` utilities (via `--color-elevation`). |

### Scales

Multi-step scales under `scales.<key>`.

| Preset key | CSS variables | Tailwind utility |
| --- | --- | --- |
| `rounded.xs..3xl` | `--maz-rounded-xs..3xl` | `rounded-{key}` (bridged to `--radius-{key}`) |
| `shadow.{sm, md, lg, xl, elevation}` | `--maz-shadow-style-{key}` | `shadow-{key}` (bridged to `--shadow-{key}`) |
| `color` (`'relative'` \| `'perceptual'`) | `--maz-color-<role>-50..950`, `--color-<role>-50..950` | `bg-primary-300`... (see Color scales) |

> Same naming-collision avoidance as foundation: `rounded.*` (instead of `radius.*`) and `shadow-style-*` (instead of plain `shadow-*`) keep the bridge cycle-free under `prefix(maz)`.

### Components

Per-component knobs under `components.<key>`. All optional - omit to fall back to the surface tokens.

| Preset key | CSS variable | Notes |
| --- | --- | --- |
| `btn.font-weight` | `--maz-btn-font-weight` | Font-weight on `.m-btn`. Defaults to `'500'`. |
| `container.bg.light` / `.dark` | `--maz-container-bg` (per mode) | Background of `Card`, `Container`, `Dialog`, `Popover`, `Drawer`, … Defaults to `var(--maz-surface)`. Bridged to `--color-container`. |
| `input.bg.light` / `.dark` | `--maz-input-bg` (per mode) | Background of `Input`, `Textarea`, `Select`, `Checkbox`, … Defaults to `var(--maz-surface)` light, `var(--maz-surface-400)` dark. Bridged to `--color-input`. |
| `dialog.max-width` | `--maz-dialog-max-width` | Max-width of `MazDialog` on tablet and up. Defaults to `38rem` (`33.25rem` on `ocean`). Overridable via the `max-width` prop. |
| `dialog.min-width` | `--maz-dialog-min-width` | Min-width of `MazDialog` on tablet and up. Defaults to `32rem` (`28rem` on `ocean`). Overridable via the `min-width` prop. |

### Sample output

Each base color is emitted as a `light-dark()` value; scales `--maz-X-50` through `--maz-X-950` are derived via OKLCh relative color syntax so they automatically follow base color overrides at runtime, preserving chroma at every step.

```css
:root {
  color-scheme: light dark;

  /* Base colors - one declaration each, resolved by the browser */
  --maz-primary: light-dark(oklch(0.65 0.19 254), oklch(0.72 0.18 254));
  --maz-primary-foreground: light-dark(oklch(1 0 0), oklch(0.16 0.01 254));
  --maz-surface: light-dark(oklch(1 0 0), oklch(0.18 0.01 254));
  --maz-foreground: light-dark(oklch(0.26 0.01 254), oklch(0.96 0.005 254));

  /* Auto-generated 50-950 scale - derived via relative color syntax */
  --maz-primary-500: var(--maz-primary);
  --maz-primary-600: oklch(from var(--maz-primary) clamp(0, calc(l - 0.07), 1) c h);
  /* ... */

  /* Foundation */
  --maz-base-font-size: 14px;
  --maz-border-width: 1px;
  --maz-space: 0.25rem;
  --maz-font-family: Manrope, sans-serif, …;
  --maz-motion-normal: 200ms;

  /* Scales */
  --maz-rounded-md: 0.7rem;
  --maz-shadow-style-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), …;
}

/* With darkModeStrategy: 'class' - explicit user choice overrides the system */
.dark  { color-scheme: only dark; }
.light { color-scheme: only light; }
```

For deeper detail on the generated CSS contract, see the [`@maz-ui/themes` README](https://github.com/LouisMazel/maz-ui/tree/master/packages/themes#generated-css-variables).

## Usage with Nuxt

For Nuxt users, check the [dedicated Nuxt documentation](https://maz-ui.com/ecosystem/nuxt.md) which covers installation and framework-specific configuration.

## Migration from Legacy System

If you're using the legacy theme system with CLI:

```typescript [Before (CLI)]
// maz-ui.config.ts
export default defineConfig({
  theme: {
    colors: {
      primary: 'hsl(210, 100%, 56%)',
      secondary: 'hsl(164, 76%, 46%)'
    }
  }
})
```

```typescript [After (@maz-ui/themes)]
// main.ts
import { definePreset, mazUi } from '@maz-ui/themes'
import { MazUiTheme } from '@maz-ui/themes/plugin'

const myTheme = definePreset({
  base: mazUi,
  overrides: {
    colors: {
      light: {
        primary: 'oklch(0.65 0.19 254)',
        secondary: 'oklch(0.72 0.16 168)'
      }
    }
  }
})

app.use(MazUiTheme, { preset: myTheme })
```

The new system offers much more flexibility and performance!
