ThemeSwitcher

A labeled control for choosing between light, dark, and system appearance. Each option is a preview card depicting that mode, and selecting one drives the global <html data-theme> through useTheme, so a bare <ThemeSwitcher /> switches the whole app with no wiring.

ThemeSwitch between light, dark, or system theme
vue
<script setup lang="ts">
import { ThemeSwitcher } from 'frappe-ui'
</script>

<template>
  <ThemeSwitcher />
</template>

Branding

Pass a name and a logo (an image URL or a Component) to show your branding inside the theme panel previews. The label and description props set the heading of the component.

AppearancePick how your branding looks on different theme modes
vue
<script setup lang="ts">
import { ThemeSwitcher } from 'frappe-ui'
</script>

<template>
  <ThemeSwitcher
    name="CRM"
    logo="https://raw.githubusercontent.com/frappe/crm/develop/.github/logo.svg"
    label="Appearance"
    description="Pick how your branding looks on different theme modes"
  />
</template>

Toggle button

In a header or sidebar you rarely want the full card preview. You want a single labelled button that flips the state. Build one from the same useTheme composable: because both controls share the one <html data-theme>, they stay in sync, and the button drops straight into a sidebar or menu.

vue
<script setup lang="ts">
import { Button, useTheme } from 'frappe-ui'

// The full ThemeSwitcher is for settings panels; in a header, sidebar, or menu
// you usually want a single labelled button. Build it from the same `useTheme`
// composable so both controls drive the one `<html data-theme>`. Drops straight
// into a frappe-ui sidebar.
const { currentTheme, toggleTheme } = useTheme()
</script>

<template>
  <Button @click="toggleTheme">
    Toggle theme
    <template #prefix>
      <LucideSun
        v-if="currentTheme === 'dark'"
        class="size-4"
      />
      <LucideMoon
        v-else
        class="size-4"
      />
    </template>
  </Button>
</template>

In a user menu

A user menu is the most common home for theme switching. Nest the three options as a hover submenu inside a Dropdown, drive them through the same useTheme singleton, and mark the active one with selected so the menu always reflects the shared <html data-theme>.

vue
<script setup lang="ts">
import { computed } from 'vue'
import { Avatar, Dropdown, useTheme, type DropdownOptions } from 'frappe-ui'

const { currentTheme, setTheme } = useTheme()

const themes = [
  { label: 'Light', icon: 'lucide-sun', value: 'light' },
  { label: 'Dark', icon: 'lucide-moon', value: 'dark' },
  { label: 'System', icon: 'lucide-monitor', value: 'system' },
] as const

const menuOptions = computed<DropdownOptions>(() => [
  { icon: 'lucide-user', label: 'My Profile', onClick: () => {} },
  {
    icon: 'lucide-sun-moon',
    label: 'Theme',
    submenu: themes.map((theme) => ({
      label: theme.label,
      icon: theme.icon,
      selected: currentTheme.value === theme.value,
      onClick: () => setTheme(theme.value),
    })),
  },
  { icon: 'lucide-log-out', label: 'Log out', onClick: () => {} },
])

const crmLogo = 'https://raw.githubusercontent.com/frappe/crm/develop/.github/logo.svg'
</script>

<template>
  <Dropdown :options="menuOptions">
    <template #default="{ open }">
      <button
        class="flex items-center gap-2 rounded-md px-2 py-2"
        :class="open ? 'bg-surface-gray-3' : 'hover:bg-surface-gray-2'"
      >
        <Avatar shape='square' :image="crmLogo" label="CRM" />
        <div class="text-start">
          <div class="text-base font-medium leading-none text-ink-gray-9">
            CRM
          </div>
          <div class="mt-1 text-sm text-ink-gray-7">faris@example.com</div>
        </div>
        <LucideChevronDown class="ml-2 size-4 text-ink-gray-5" />
      </button>
    </template>

    <template #item-suffix="{ selected }">
      <LucideCheck v-if="selected" class="size-4 text-ink-gray-7" />
    </template>
  </Dropdown>
</template>

useTheme

The component is backed by the useTheme composable, exported from the library. Its state is a shared singleton, so every consumer (the switcher, a sidebar toggle, a user-menu entry) stays in sync with the single <html data-theme> source of truth.

There is nothing to set up. The first useTheme() call restores the saved theme (falling back to system) and starts following the OS preference. Calling it near your app root simply makes that happen as early as possible.

ts
import { useTheme } from 'frappe-ui'

const { currentTheme, setTheme, toggleTheme } = useTheme()

The selection persists to localStorage under the theme key and is reapplied on the next load.

MemberTypeDescription
currentThemeRef<Theme>The selected theme: 'light' | 'dark' | 'system'.
setTheme(theme: Theme) => voidSets the theme, applies data-theme, and persists to storage.
toggleTheme() => voidFlips between light and dark.
initializeTheme() => voidRestores the saved theme (or system). Run once automatically.
getSystemTheme() => 'light' | 'dark'Resolves the current OS preference.

Avoid the flash. The theme is applied from JavaScript once the app loads, so a page that ships without a data-theme briefly shows the default theme before switching. Set an initial data-theme on your <html>, or inline a small script that reads localStorage.theme, to render the right theme from the first paint.

API Reference

Show types
typescript
import type { Component } from 'vue'
import type { Theme } from '../../utils/theme'

export interface ThemeSwitcherProps {
  /** Selected theme. Falls back to the shared `useTheme` state when unbound. */
  modelValue?: Theme

  /**
   * Heading rendered above the options.
   * @default "Theme"
   */
  label?: string

  /**
   * Helper text rendered below the heading.
   * @default "Switch between light, dark, or system theme"
   */
  description?: string

  /**
   * Brand logo shown inside each preview. A string is treated as an image
   * source; a component value is rendered with `<component :is>`.
   */
  logo?: string | Component

  /** Brand name shown inside each preview. */
  name?: string

  /**
   * Overrides the per-option labels. For richer per-item content, use the
   * `#item-label` slot, which takes precedence over this prop.
   * @default { light: "Light", dark: "Dark", system: "System" }
   */
  themeLabels?: Partial<Record<Theme, string>>
}
modelValue
Theme

Selected theme. Falls back to the shared `useTheme` state when unbound.

label
= "Theme"
string

Heading rendered above the options.

description
= "Switch between light, dark, or system theme"
string

Helper text rendered below the heading.

logo
string | Component

Brand logo shown inside each preview. A string is treated as an image source; a component value is rendered with `<component :is>`.

name
= ""
string

Brand name shown inside each preview.

themeLabels
Partial<Record<Theme, string>>

Overrides the per-option labels. For richer per-item content, use the `#item-label` slot, which takes precedence over this prop.

label

Overrides the heading content.

description

Overrides the helper-text content.

item-label
{ value: Theme; }

Overrides a single option's label. Receives the option's `value`. Falls back to the `themeLabels` prop, then the built-in label.

update:modelValue
[theme: Theme]

Fired when the model value changes.