vue-multiple-themes
Dynamic multi-theme support for Vue 3 — CSS custom properties, TailwindCSS (with full opacity modifier support), WCAG contrast utilities, white-label brand contexts, and a reactive composable API.
Most Vue theming solutions only handle light/dark toggling. vue-multiple-themes is a complete multi-theme engine for production applications.
| Feature | vue-multiple-themes | @vueuse useColorMode | nuxt-color-mode |
|---|---|---|---|
| Multiple themes (3+) | ✅ Unlimited | ||
| TailwindCSS v3 & v4 plugin | ✅ Built-in | ❌ | ❌ |
Opacity modifiers (bg-primary/50) |
✅ | ❌ | ❌ |
| WCAG contrast utilities | ✅ 5+ functions | ❌ | ❌ |
| Theme generation from 1 color | ✅ | ❌ | ❌ |
| White-label / namespace | ✅ createBrandContext() |
❌ | ❌ |
| Color utility library | ✅ 20+ functions | ❌ | ❌ |
| Preset themes | ✅ 7 included | ❌ | ❌ |
| System preference | ✅ | ✅ | ✅ |
| TypeScript | ✅ Full | ✅ | ✅ |
| Zero dependencies | ✅ | ❌ | ❌ |
Prerequisites: pnpm v9+ and Node.js v18+
# Install all workspace dependencies
pnpm install
# Run the interactive playground (Vite dev server → http://localhost:5173)
pnpm dev
# Build the library
pnpm build
# Build the playground for production (deployed to GitHub Pages)
pnpm build:playground- Vue 3 Optimized — leverage the latest Composition API and
<script setup> - TypeScript — full type definitions included
- CSS custom properties — semantic
--vmt-*variables injected automatically - TailwindCSS plugin —
bg-vmt-primary,text-vmt-foreground, etc., with full opacity modifier support (bg-vmt-primary/50) - Tailwind v3 & v4 — dedicated plugin for each major version
- 7 preset themes — light, dark, sepia, ocean, forest, sunset, winter
- Dynamic theme generation — create themes from a single brand color
- Color utilities — lighten, darken, mix, contrast ratio, WCAG compliance
useTheme()composable — reactive, SSR-safe, localStorage-persistent, luminance-basedisDark- System preference detection — auto-select light/dark based on OS setting
- Bring-your-own icons —
VmtIcon :as="SunIcon"forwards to any Vue icon component (lucide-vue-next,@heroicons/vue, …); no icon data shipped in the bundle - White-label / multi-tenant ready —
createBrandContext()creates fully isolated namespaced theme engines - Headless components —
VmtThemePickerwith keyboard nav & ARIA,VmtIcon - Zero runtime dependencies (only
vuepeer dependency)
# pnpm (recommended)
pnpm add vue-multiple-themes
# npm
npm install vue-multiple-themes
# yarn
yarn add vue-multiple-themes// main.ts
import { createApp } from 'vue';
import { VueMultipleThemesPlugin } from 'vue-multiple-themes';
import App from './App.vue';
const app = createApp(App);
app.use(VueMultipleThemesPlugin, {
defaultTheme: 'dark',
strategy: 'attribute', // 'attribute' | 'class' | 'both'
storage: 'localStorage', // 'localStorage' | 'sessionStorage' | 'none'
});
app.mount('#app');<!-- App.vue -->
<script setup lang="ts">
import { useTheme, PRESET_THEMES } from 'vue-multiple-themes';
const ts = useTheme({ themes: PRESET_THEMES });
// ts.current, ts.isDark, ts.theme are reactive (auto-unwrapped)
</script>
<template>
<button v-for="t in ts.themes" :key="t.name" @click="ts.setTheme(t.name)">
{{ t.label }} (Active: {{ ts.current === t.name }})
</button>
<p>Dark mode: {{ ts.isDark }}</p>
</template>VmtIcon is a thin forwarder — it accepts any Vue icon component via the as prop and
forwards size, color, and strokeWidth. No SVG data lives in the library.
# Install any Vue icon library — e.g.
pnpm add lucide-vue-nextimport { Sun, Moon, Palette } from 'lucide-vue-next'
import { VmtIcon } from 'vue-multiple-themes'<!-- Render any icon through VmtIcon -->
<VmtIcon :as="Sun" :size="24" color="currentColor" />
<VmtIcon :as="Moon" :size="20" :stroke-width="1.5" />
<!-- Or use the component directly — same props accepted -->
<Sun :size="24" color="currentColor" />Assign icons to theme definitions:
import { Sun, Moon } from 'lucide-vue-next'
const themes = [
{ name: 'light', label: 'Light', icon: Sun, colors: { ... } },
{ name: 'dark', label: 'Dark', icon: Moon, colors: { ... } },
]Attach icons to the ready-made PRESET_THEMES:
import { Sun, Moon, Monitor, Coffee, Leaf, Droplets, Flame, Snowflake } from 'lucide-vue-next'
import { PRESET_THEMES } from 'vue-multiple-themes'
import type { Component } from 'vue'
const presetIcons: Record<string, Component> = {
light: Sun, dark: Moon, system: Monitor,
cafe: Coffee, nature: Leaf, ocean: Droplets,
flame: Flame, nord: Snowflake,
}
const themes = PRESET_THEMES.map(t => ({ ...t, icon: presetIcons[t.name] }))Themes inject dual CSS variables on the target element (default: <html>):
/* Channel format (for Tailwind opacity modifiers) */
--vmt-primary: 59 130 246;
/* Full color (for direct CSS use) */
--vmt-primary-color: rgb(59 130 246);Use the -color suffixed variables in custom CSS:
.card {
background: var(--vmt-background-color);
color: var(--vmt-foreground-color);
border: 1px solid var(--vmt-border-color);
}
/* Manual opacity using the channel format */
.overlay {
background: rgb(var(--vmt-primary) / 0.5);
}// tailwind.config.js
import { createVmtPlugin } from 'vue-multiple-themes/tailwind';
import { PRESET_THEMES } from 'vue-multiple-themes';
export default {
content: ['./src/**/*.{vue,ts,tsx}'],
plugins: [
createVmtPlugin({
themes: PRESET_THEMES,
strategy: 'both',
darkThemes: ['dark'], // enables Tailwind `dark:` modifier
}),
],
};Opacity modifiers work out of the box:
<div class="bg-vmt-primary/50 text-vmt-text border-vmt-border/75">
<span class="text-vmt-primary/80">Semi-transparent text</span>
</div>All Tailwind utilities are available: bg-, text-, border-, ring-, divide-, placeholder-, outline-, shadow-, accent-, caret-, fill-, stroke-, gradients (from-, via-, to-), and more.
import { generateVmtCssForV4 } from 'vue-multiple-themes/tailwind-v4';
import { PRESET_THEMES } from 'vue-multiple-themes';
// Outputs @theme and @custom-variant blocks for your CSS
const css = generateVmtCssForV4({
themes: PRESET_THEMES,
strategy: 'both',
darkThemes: ['dark'],
});| Name | Description |
|---|---|
light |
Clean white + indigo |
dark |
Dark gray + violet |
sepia |
Warm parchment browns |
ocean |
Deep sea blues |
forest |
Rich greens |
sunset |
Warm oranges & reds |
winter |
Icy blues & whites |
import { PRESET_THEMES, oceanTheme, forestTheme } from 'vue-multiple-themes';Create light/dark theme pairs from a single brand color:
import { generateThemePair } from 'vue-multiple-themes';
const [light, dark] = generateThemePair('#6366f1'); // indigoGenerate a full color scale:
import { generateColorScale } from 'vue-multiple-themes';
const scale = generateColorScale('#6366f1', 9); // 9-step paletteAll utilities are SSR-safe (no DOM dependency) and tree-shakeable:
import {
lighten,
darken,
mix,
contrastRatio,
autoContrast,
checkContrast,
complementary,
triadic,
analogous,
normalizeToRgbChannels,
} from 'vue-multiple-themes';
lighten('#6366f1', 0.2); // lighter hex
darken('#6366f1', 0.3); // darker hex
mix('#ff0000', '#0000ff', 0.5); // purple blend
contrastRatio('#000', '#fff'); // 21
autoContrast('#6366f1'); // '#ffffff' or '#000000'
checkContrast('#6366f1', '#fff'); // { ratio, aa, aaa, aaLarge, aaaLarge }
normalizeToRgbChannels('#6366f1'); // '99 102 241'| Option | Type | Default | Description |
|---|---|---|---|
themes |
ThemeDefinition[] |
— | Available themes (required) |
defaultTheme |
string |
first theme | Initial theme name |
strategy |
'attribute' | 'class' | 'both' |
'attribute' |
How theme is applied to DOM |
target |
string |
'html' |
Target element selector |
storage |
'localStorage' | 'sessionStorage' | 'none' |
'localStorage' |
Where to persist the active theme |
storageKey |
string |
'vmt-theme' |
Storage key for persistence |
namespace |
string |
— | Isolate state for white-label / multi-tenant setups |
respectSystemPreference |
boolean |
false |
Auto-select theme matching OS mode |
onThemeChange |
(newTheme, oldTheme) => void |
— | Callback on every theme change |
Returns:
| Property | Type | Description |
|---|---|---|
current |
readonly string |
Active theme name (reactive) |
theme |
readonly ThemeDefinition |
Active theme definition (reactive) |
isDark |
readonly boolean |
Luminance-based dark detection (reactive) |
themes |
readonly ThemeDefinition[] |
All available themes |
resolvedColors |
readonly Record<string, {r,g,b}> |
Active theme colors as RGB objects |
setTheme() |
(name: string) => void |
Activate a theme by name |
nextTheme() |
() => void |
Advance to the next theme |
prevTheme() |
() => void |
Go back to the previous theme |
toggleTheme() |
() => void |
Toggle between first two themes |
Note:
current,theme,isDark, andresolvedColorsare reactive properties wrapped inreactive(). Use them directly in templates:{{ ts.current }}. Towatch()them, use a getter:watch(() => ts.current, ...).
| Option | Type | Description |
|---|---|---|
namespace |
string (required) |
Unique brand identifier — scopes style tag, singleton, and provide key |
+ all useTheme options |
All ThemeOptions fields accepted as context defaults |
Returns: { namespace, useTheme(overrides?), BrandPlugin }
Use createBrandContext() when you need multiple independent theme engines in the same
Vue app — e.g. white-label products, micro-frontends, or embeddable widgets.
Each context isolates:
- Its injected
<style>tag (id="vmt-theme-styles-<namespace>") - Its singleton reactive state (keyed by
<namespace>:<storageKey>) - Its Vue
providekey ("vmt:options:<namespace>") - Its Tailwind color namespace (
bg-acme-primaryvsbg-beta-primary)
import { createBrandContext } from 'vue-multiple-themes'
export const acme = createBrandContext({
namespace: 'acme',
storageKey: 'acme-theme',
cssVarPrefix: '--acme-',
themes: acmeThemes,
defaultTheme: 'acme-light',
strategy: 'attribute',
})
export const beta = createBrandContext({
namespace: 'beta',
storageKey: 'beta-theme',
cssVarPrefix: '--beta-',
themes: betaThemes,
defaultTheme: 'beta-light',
strategy: 'attribute',
})
// main.ts — install both plugins
app.use(acme.BrandPlugin)
app.use(beta.BrandPlugin)<!-- Component.vue -->
<script setup lang="ts">
import { acme, beta } from './brands'
const acmeState = acme.useTheme()
const betaState = beta.useTheme()
</script>
<template>
<button @click="acmeState.toggleTheme()">Acme: {{ acmeState.current }}</button>
<button @click="betaState.toggleTheme()">Beta: {{ betaState.current }}</button>
</template>-
CSS variable format changed —
--vmt-primarynow contains RGB channels (59 130 246) instead of hex. Use--vmt-primary-colorfor the fullrgb()value in custom CSS. -
Tailwind plugin API changed —
createVmtPlugin()now requires athemesoption:// Before (v5) createVmtPlugin() // After (v6) createVmtPlugin({ themes: PRESET_THEMES })
-
persistoption renamed — Usestorage: 'localStorage'instead ofpersist: true. -
useTheme()return type changed — Returns areactive()object. Properties are accessed directly (no.valueneeded).currentTheme/currentNamerenamed totheme/current. -
isDarkuses luminance — Now calculated from background color luminance instead of name matching. -
Icon API changed —
ThemeDefinition.iconis nowComponent(notstring).VmtIconuses:asinstead of:name. No icon data is bundled — bring your own library:// Before (v5 — string name, non-functional) { name: 'light', icon: 'sun', colors: { ... } } // <VmtIcon name="sun" :size="24" /> // After (v6 — real Vue component) import { Sun } from 'lucide-vue-next' { name: 'light', icon: Sun, colors: { ... } } // <VmtIcon :as="Sun" :size="24" />
Full documentation and live demos:
https://pooyagolchian.github.io/vue-multiple-themes/
- Getting Started
- API Reference
- TailwindCSS Integration
- White-Label / Multi-Tenant
- Theme Generation
- Color Utilities
- Nuxt / SSR
- Comparison with Alternatives
This project includes an llms.txt file following the llmstxt.org specification, providing structured API documentation for AI assistants.
MIT © Pooya Golchian