Skip to content

Theme System

The Design System provides a comprehensive and performant theme system with support for dark mode, high contrast, and reduced motion.

Overview

Themes × Modes Architecture

The system is built on two orthogonal dimensions:

DimensionValuesRole
Theme (data-theme)default | ocean | forest | sunsetVisual identity / color palette
Mode (data-theme-mode)light | dark | systemBrightness / contrast

Each theme is available in both light and dark mode.

Available Themes

  • Default - Classic neutral palette, suitable for any context
  • Ocean - Maritime palette with ocean blue and coral accents
  • Forest - Natural inspiration with forest greens and autumn orange
  • Sunset - Warm ambiance with pink, violet, and orange

Available Modes

  • light - Light mode (default)
  • dark - Dark mode optimized for low-light environments
  • system - Follows system preference automatically (prefers-color-scheme)

Default Behavior

Without any HTML attributes, the default theme in light mode is applied via a CSS fallback on html:not([data-theme]). The system mode is not enabled by default - the user must explicitly choose it.

Deprecated Names

The theme names light and dark (formerly standalone themes) are deprecated. They continue working in CSS and JS for backward compatibility but emit a console warning. Use default + themeMode instead.

CSS Strategy - Dual HTML Attributes

The theme and mode are two separate HTML attributes on <html>:

html
<!-- Default theme in light mode -->
<html data-theme="default" data-theme-mode="light">

<!-- Ocean theme in dark mode -->
<html data-theme="ocean" data-theme-mode="dark">

<!-- Forest theme following system preference -->
<html data-theme="forest" data-theme-mode="system">

Backward compatibility is ensured: [data-theme='light'] and [data-theme='dark'] still work in CSS (aliases defined in default/index.scss).

Installation and Configuration

Build Configuration

To optimize CSS bundle size, you can choose which themes to include during build.

typescript
// theme.config.ts
export const themeConfig = {
  themes: ['default', 'ocean', 'forest', 'sunset'],
  defaultTheme: 'default',
  defaultThemeMode: 'light',
  prefix: 'su',
  highContrast: true,
  reducedMotion: true,
  storageKey: 'su-theme-config',
};

Bundle size: ~425 KB total (lib + styles)

Minimal Configuration

typescript
// theme.config.ts
export const themeConfig = {
  themes: ['default'],           // Default theme only
  defaultTheme: 'default',
  defaultThemeMode: 'light',
  storageKey: 'my-company-theme',
};

Custom Configuration

typescript
// theme.config.ts
export const themeConfig = {
  themes: ['default', 'ocean'], // Only the themes you need
  defaultTheme: 'default',
  defaultThemeMode: 'light',
  storageKey: 'my-company-theme',
};

Loading in SCSS

scss
// main.scss
@use './core/theme-loader' as loader;

// Option 1: Specific themes
@include loader.load-themes('default', 'ocean');

// Option 2: All themes
@include loader.load-all-themes();

TypeScript Types

typescript
// Theme (visual identity)
type ThemeName = 'default' | 'ocean' | 'forest' | 'sunset'

// Mode (brightness)
type ThemeMode = 'light' | 'dark' | 'system'

// Contrast and motion (unchanged)
type ContrastMode = 'normal' | 'high' | 'auto'
type MotionMode   = 'normal' | 'reduce' | 'auto'

// Deprecated -  backward compatibility only
type DeprecatedThemeName = 'light' | 'dark'

Usage

useTheme Composable

The useTheme composable is the main entry point for managing themes.

vue
<script setup lang="ts">
import { useTheme } from '@surgeui/ds-vue';

const {
  // Reactive state
  themeName,            // Ref<ThemeName> -  active visual identity
  themeMode,            // Ref<ThemeMode> -  'light' | 'dark' | 'system'
  contrastMode,         // Ref<ContrastMode>
  motionMode,           // Ref<MotionMode>

  // Computed
  effectiveTheme,       // ComputedRef<ThemeName> -  resolved theme (never 'system')
  effectiveThemeMode,   // ComputedRef<'light' | 'dark'> -  resolved mode
  isDarkMode,           // ComputedRef<boolean>

  // Actions
  setTheme,             // (theme: ThemeName) => void
  setThemeMode,         // (mode: ThemeMode) => void
  toggleMode,           // () => void -  toggle between light and dark
  cycleTheme,           // () => void -  cycle through available themes
  clearConfig,          // () => void -  reset to defaultTheme + defaultThemeMode
} = useTheme();
</script>

Composable Options

typescript
useTheme({
  // Override available themes
  availableThemes: ['default', 'ocean'],

  // Default theme
  defaultTheme: 'default',

  // Default mode (NEW)
  defaultThemeMode: 'light',

  // Custom localStorage key
  storageKey: 'my-app-theme',

  // Disable persistence
  persist: false
});

Changing the Theme

Change Visual Identity

vue
<template>
  <button @click="setTheme('ocean')">Ocean Theme</button>
  <button @click="setTheme('forest')">Forest Theme</button>
  <button @click="setTheme('default')">Default Theme</button>
</template>

<script setup lang="ts">
import { useTheme } from '@surgeui/ds-vue';

const { setTheme } = useTheme();
</script>

Change Brightness Mode

vue
<template>
  <button @click="setThemeMode('dark')">Dark Mode</button>
  <button @click="setThemeMode('light')">Light Mode</button>
  <button @click="setThemeMode('system')">Follow System</button>
</template>

<script setup lang="ts">
import { useTheme } from '@surgeui/ds-vue';

const { setThemeMode } = useTheme();
</script>

Toggle Light / Dark

vue
<template>
  <button @click="toggleMode">
    {{ isDarkMode ? '☀️ Light' : '🌙 Dark' }}
  </button>
</template>

<script setup lang="ts">
import { useTheme } from '@surgeui/ds-vue';

const { toggleMode, isDarkMode } = useTheme();
</script>

Cycle Through Themes

vue
<template>
  <button @click="cycleTheme">
    Next theme: {{ effectiveTheme }}
  </button>
</template>

<script setup lang="ts">
import { useTheme } from '@surgeui/ds-vue';

const { cycleTheme, effectiveTheme } = useTheme();
</script>

Components

ThemeSelector

Complete component for theme selection with visual interface.

vue
<template>
  <ThemeSelector />
</template>

<script setup lang="ts">
import ThemeSelector from '@/components/ThemeSelector.vue';
</script>

Features:

  • Visual preview of each theme
  • Mode selection (light / dark / system)
  • Contrast selection (normal / high)
  • Animation configuration (normal / reduced)
  • Detected system preferences display
  • Reset button

ThemeToggle

Compact button to quickly toggle between modes.

vue
<template>
  <ThemeToggle />
</template>

<script setup lang="ts">
import ThemeToggle from '@/components/ThemeToggle.vue';
</script>

Features:

  • Dynamic icon based on active mode
  • Mode label (hidden on mobile)
  • Transition animation
  • Cycle through all available themes

Accessibility

High Contrast

The system automatically supports high contrast mode, essential for accessibility.

Automatic Detection

vue
<script setup lang="ts">
import { useTheme } from '@surgeui/ds-vue';

const { effectiveContrast, systemContrast } = useTheme();
// effectiveContrast: 'normal' | 'high'
// systemContrast: detected system preference
</script>

Manual Configuration

vue
<template>
  <select :value="contrastMode" @change="setContrast($event.target.value)">
    <option value="auto">Automatic</option>
    <option value="normal">Normal</option>
    <option value="high">High</option>
  </select>
</template>

<script setup lang="ts">
import { useTheme } from '@surgeui/ds-vue';

const { contrastMode, setContrast } = useTheme();
</script>

High Contrast Mode Behavior

  • Pure colors (pure black / pure white)
  • Reinforced borders
  • Removal of transparency effects
  • Minimum contrast ratio of 7:1 (WCAG AAA)

Reduced Motion

Respects prefers-reduced-motion for users sensitive to movement.

vue
<script setup lang="ts">
import { useTheme } from '@surgeui/ds-vue';

const { motionMode, setMotion } = useTheme();
</script>

<template>
  <select :value="motionMode" @change="setMotion($event.target.value)">
    <option value="auto">Automatic</option>
    <option value="normal">Enabled</option>
    <option value="reduce">Reduced</option>
  </select>
</template>

Effect:

  • All transitions become instantaneous (0ms)
  • Animations are disabled
  • Scale transforms are neutralized

Usage in Components

Access Color Tokens

Use automatically generated CSS variables:

vue
<style scoped lang="scss">
.my-component {
  // Text
  color: var(--su-text-primary);

  // Background
  background-color: var(--su-bg-surface);

  // Border
  border: 1px solid var(--su-border-default);

  // Primary color
  background-color: var(--su-primary-default);

  // Inverted section (dark bg on light page, or the reverse)
  &--inverted {
    background-color: var(--su-bg-inverse);
    color: var(--su-text-on-inverse);
    border-color: var(--su-border-inverse);
  }

  // States
  &--success {
    color: var(--su-state-success);
    background-color: var(--su-state-success-bg);
  }

  &:hover {
    background-color: var(--su-bg-hover);
  }

  &:focus-visible {
    border-color: var(--su-border-focus);
  }
}
</style>

Utility Mixins

vue
<style scoped lang="scss">
@use '@/styles/core/mixins' as *;

.my-button {
  // Automatic transitions with reduced-motion support
  @include transition(background-color, transform);

  // Accessible focus ring
  &:focus-visible {
    @include focus-ring;
  }

  // Complete interactive states
  @include interactive-states;

  // Elevated surface
  @include surface($elevated: true);
}
</style>

Advanced Customization with Custom CSS Variables

The theme system supports dynamic color customization via custom CSS variables. This allows you to override theme colors without creating a complete new theme.

How it Works

The custom variables system uses a CSS fallback mechanism:

scss
// Automatically generated for each token
--su-primary-default: var(--su-custom-primary-default, #3b82f6);

Flow:

  1. User defines --su-custom-primary-default: #dc2626
  2. CSS resolves var(--su-custom-primary-default, ...)#dc2626
  3. All components using --su-primary-default receive the new value
  4. If no custom is defined, fallback to theme value

useCustomTheme Composable

The useCustomTheme composable manages custom CSS variables reactively.

vue
<script setup lang="ts">
import { useCustomTheme } from '@surgeui/ds-vue'

const {
  applyCustomTheme,    // Apply entire custom theme
  setCustomVariable,   // Set individual variable
  getCustomTheme,      // Get current custom theme
  resetCustomTheme,    // Reset all customs
  mergeWithTheme,      // Merge with existing theme
  customTheme          // Reactive theme state
} = useCustomTheme()
</script>

Usage Examples

1. Apply Complete Custom Theme

vue
<script setup lang="ts">
import { useCustomTheme } from '@surgeui/ds-vue'

const { applyCustomTheme } = useCustomTheme()

const applyBrandTheme = () => {
  applyCustomTheme({
    textPrimary: '#1f2937',
    bgSurface: '#ffffff',
    bgCanvas: '#f9fafb',
    primaryDefault: '#dc2626',
    primaryHover: '#b91c1c',
    primaryActive: '#991b1b',
    primaryText: '#ffffff',
    stateSuccess: '#059669',
    stateError: '#dc2626'
  })
}
</script>

<template>
  <button @click="applyBrandTheme">
    Apply Brand Theme
  </button>
</template>

2. Modify Individual Color

vue
<script setup lang="ts">
import { useCustomTheme } from '@surgeui/ds-vue'

const { setCustomVariable } = useCustomTheme()

// Simple color change
setCustomVariable('primaryDefault', '#3b82f6')

// Reactive with input
const handleColorChange = (color: string) => {
  setCustomVariable('primaryDefault', color)
}
</script>

<template>
  <input
    type="color"
    @input="(e) => handleColorChange((e.target as HTMLInputElement).value)"
    placeholder="Select primary color"
  >
</template>

3. Combine Theme, Mode, and Brand Colors

vue
<script setup lang="ts">
import { useTheme } from '@surgeui/ds-vue'
import { useCustomTheme } from '@surgeui/ds-vue'

const { setTheme, setThemeMode } = useTheme()
const { setCustomVariable } = useCustomTheme()

const applyDarkWithBrand = () => {
  // Switch to default theme in dark mode
  setTheme('default')
  setThemeMode('dark')

  // Override with brand colors
  setCustomVariable('primaryDefault', '#db2777')
  setCustomVariable('primaryHover', '#be185d')
}
</script>

<template>
  <button @click="applyDarkWithBrand">
    Dark Mode + Brand
  </button>
</template>

Available Custom Variables

You can customize any theme token using the --su-custom-<token-name> format:

Text:

  • --su-custom-text-primary
  • --su-custom-text-secondary
  • --su-custom-text-tertiary
  • --su-custom-text-disabled
  • --su-custom-text-inverse

Backgrounds:

  • --su-custom-bg-canvas
  • --su-custom-bg-surface
  • --su-custom-bg-surface-elevated
  • --su-custom-bg-hover
  • --su-custom-bg-active
  • --su-custom-bg-selected
  • --su-custom-bg-disabled
  • --su-custom-bg-inverse
  • --su-custom-bg-inverse-subtle

Borders:

  • --su-custom-border-default
  • --su-custom-border-subtle
  • --su-custom-border-strong
  • --su-custom-border-focus
  • --su-custom-border-disabled
  • --su-custom-border-inverse

Actions:

  • --su-custom-primary-default
  • --su-custom-primary-hover
  • --su-custom-primary-active
  • --su-custom-primary-disabled
  • --su-custom-primary-text
  • --su-custom-secondary-default
  • --su-custom-secondary-hover
  • --su-custom-secondary-active
  • --su-custom-secondary-disabled
  • --su-custom-secondary-text

States:

  • --su-custom-state-success
  • --su-custom-state-success-bg
  • --su-custom-state-warning
  • --su-custom-state-warning-bg
  • --su-custom-state-error
  • --su-custom-state-error-bg
  • --su-custom-state-info
  • --su-custom-state-info-bg

Benefits of This Approach

Dynamic - Instant changes without page reload ✅ Flexible - Customize partially or completely ✅ Performant - No SCSS recompilation, CSS only ✅ Compatible - Works with all themes and all modes ✅ Persistent - Compatible with localStorage for user preferences

Create a Custom Theme

1. New File Structure (separate light + dark tokens)

styles/themes/
├── _registry.scss
├── _schema.scss          ← Required token validation
├── default/
│   ├── _color.scss
│   ├── index.scss
│   └── tokens/
│       ├── light.scss
│       └── dark.scss
├── ocean/    (same structure)
├── forest/   (same structure)
├── sunset/   (same structure)
└── custom/   ← Your theme
    ├── index.scss
    └── tokens/
        ├── light.scss
        └── dark.scss

2. Define Light Tokens

scss
// themes/custom/tokens/light.scss
$theme-custom-light: (
  'text-primary': #1a202c,
  'text-secondary': #2d3748,
  'text-tertiary': #4a5568,

  'bg-canvas': #f7fafc,
  'bg-surface': #ffffff,

  'border-default': #cbd5e0,
  'border-focus': #4299e1,

  'primary-default': #4299e1,
  'primary-hover': #3182ce,
  'primary-text': #ffffff,

  // ... other tokens
);

3. Define Dark Tokens

scss
// themes/custom/tokens/dark.scss
$theme-custom-dark: (
  'text-primary': #f7fafc,
  'text-secondary': #e2e8f0,
  'text-tertiary': #a0aec0,

  'bg-canvas': #1a202c,
  'bg-surface': #2d3748,

  'border-default': #4a5568,
  'border-focus': #63b3ed,

  'primary-default': #63b3ed,
  'primary-hover': #4299e1,
  'primary-text': #1a202c,

  // ... other tokens
);

4. Register Theme with Both Modes

scss
// themes/custom/index.scss
@use '../_registry' as registry;
@use './tokens/light' as *;
@use './tokens/dark' as *;

[data-theme='custom'][data-theme-mode='light'] {
  @include registry.generate-theme-vars($theme-custom-light);
}

[data-theme='custom'][data-theme-mode='dark'] {
  @include registry.generate-theme-vars($theme-custom-dark);
}

5. Add TypeScript Metadata

typescript
// composables/useTheme.ts
const ALL_THEMES: ThemeMetadata[] = [
  // ... other themes
  {
    id: 'custom',
    name: 'Custom',
    description: 'My custom theme',
    category: 'color',
    preview: {
      primary: '#4299e1',
      background: '#f7fafc',
      surface: '#ffffff'
    },
    available: themeConfig.themes.includes('custom')
  }
];

API Reference

useTheme(options?)

Options

typescript
interface UseThemeOptions {
  availableThemes?: ThemeName[];
  defaultTheme?: ThemeName;
  defaultThemeMode?: ThemeMode;  // NEW
  storageKey?: string;
  persist?: boolean;
}

Return

typescript
{
  // Reactive state
  themeName: Ref<ThemeName>;
  themeMode: Ref<ThemeMode>;             // NEW
  contrastMode: Ref<ContrastMode>;
  motionMode: Ref<MotionMode>;

  // Computed
  effectiveTheme: ComputedRef<ThemeName>;
  effectiveThemeMode: ComputedRef<'light' | 'dark'>;  // NEW
  effectiveContrast: ComputedRef<'normal' | 'high'>;
  effectiveMotion: ComputedRef<'normal' | 'reduce'>;
  systemContrast: ComputedRef<'normal' | 'high'>;
  systemMotion: ComputedRef<'normal' | 'reduce'>;
  isDarkMode: ComputedRef<boolean>;

  // Data
  availableThemes: ComputedRef<ThemeMetadata[]>;

  // Actions
  setTheme: (theme: ThemeName) => void;
  setThemeMode: (mode: ThemeMode) => void;   // NEW
  toggleMode: () => void;                    // NEW -  replaces toggleTheme
  cycleTheme: () => void;
  setContrast: (contrast: ContrastMode) => void;
  setMotion: (motion: MotionMode) => void;
  clearConfig: () => void;
}

toggleTheme deprecated

toggleTheme() is kept for backward compatibility but emits a console warning. Use toggleMode() instead.

Practical Examples

Dashboard with Theme Selector

vue
<template>
  <div class="dashboard">
    <header class="dashboard-header">
      <h1>My Dashboard</h1>
      <ThemeToggle />
    </header>

    <aside class="dashboard-sidebar">
      <ThemeSelector />
    </aside>

    <main class="dashboard-content">
      <!-- Content -->
    </main>
  </div>
</template>

<script setup lang="ts">
import { useTheme } from '@surgeui/ds-vue';
import ThemeToggle from '@/components/ThemeToggle.vue';
import ThemeSelector from '@/components/ThemeSelector.vue';

useTheme();
</script>

<style scoped lang="scss">
.dashboard {
  min-height: 100vh;
  background-color: var(--su-bg-canvas);
  color: var(--su-text-primary);
}

.dashboard-header {
  display: flex;
  justify-content: space-between;
  padding: var(--su-spacing-4);
  background-color: var(--su-bg-surface);
  border-bottom: 1px solid var(--su-border-default);
}
</style>

Persistent User Preference

vue
<script setup lang="ts">
import { watch } from 'vue';
import { useTheme } from '@surgeui/ds-vue';

const { themeName, themeMode, effectiveThemeMode } = useTheme({
  defaultTheme: 'default',
  defaultThemeMode: 'system',
  storageKey: 'user-preferences-theme',
  persist: true
});

// Analytics tracking on mode change
watch(effectiveThemeMode, (newMode) => {
  console.log('Mode changed to:', newMode);
  // analytics.track('theme_mode_changed', { mode: newMode });
});
</script>

Force Theme for a Section

vue
<template>
  <!-- Force default theme in dark mode for this section -->
  <div data-theme="default" data-theme-mode="dark" class="promo-section">
    <h2>Section with Forced Theme</h2>
    <p>This section stays dark even if the app is in light mode</p>
  </div>
</template>

<style scoped lang="scss">
.promo-section {
  padding: var(--su-spacing-8);
  background-color: var(--su-bg-canvas);
  color: var(--su-text-primary);
}
</style>

Inverted Section with Inversion Tokens

vue
<template>
  <!-- Section that contrasts with context without forcing a full theme -->
  <div class="hero-section">
    <h1>Compelling Title</h1>
    <p>Featured description</p>
  </div>
</template>

<style scoped lang="scss">
.hero-section {
  background-color: var(--su-bg-inverse);
  color: var(--su-text-on-inverse);
  border: 1px solid var(--su-border-inverse);

  // Secondary surface on inverted background
  .hero-section__card {
    background-color: var(--su-surface-inverse);
  }
}
</style>

Performance

Built-in Optimizations

  • CSS Variables: Instant theme change without reload
  • Dual attributes: CSS selectors targeted by [data-theme][data-theme-mode]
  • Lazy loading: Only configured themes are included in bundle
  • Tree-shaking: Unused themes = 0 bytes
  • localStorage: Persistence without network overhead

Size Comparison

ConfigurationTotal Bundle SizeThemes Included
Minimal~200 KBDefault only
Standard~300 KBDefault + 1 color theme
Complete~425 KBAll themes

Recommendations

Corporate Applications

Use only default to minimize bundle size.

Creative Applications

Include all themes for a rich personalized experience.

Marketing Sites

Choose 1-2 color themes aligned with your brand identity.

Troubleshooting

Theme Not Applied

Check that:

  1. Theme is included in theme.config.ts
  2. Theme is loaded in main.scss
  3. useTheme() is called in App.vue or a parent
  4. Both data-theme and data-theme-mode attributes are present on <html>

Colors Not Changing

Make sure to use CSS variables:

scss
// ❌ Wrong
color: #111827;

// ✅ Correct
color: var(--su-text-primary);

Theme Unavailable in Production

Check that the theme is in the themes list of theme.config.ts and imported in the loader.

Preferences Not Saved

Check that persist: true is configured and localStorage is accessible (not in private navigation).

Console Warning on setTheme('dark')

setTheme('dark') and setTheme('light') are deprecated. Replace with:

typescript
// Before (deprecated)
setTheme('dark')

// After
setThemeMode('dark')

Resources

Publié sous licence MIT.