Skip to content

Theme Switcher

Choose system, light, or dark with a moving selection surface.

Preview

This component uses Vue and Reka. Install the source below in your Vue or Nuxt app to try it with your theme provider.

Examples

Theme transition

A slide transition declared on this control. Duration is 500 milliseconds. Other controls keep their own settings.

This component uses Vue and Reka. Install the source below in your Vue or Nuxt app to try it with your theme provider.

Shadow borders

Use a prominent ambient shadow and inset edge in place of a solid border.

This component uses Vue and Reka. Install the source below in your Vue or Nuxt app to try it with your theme provider.

When to use

  • Show all three appearance choices in a header or settings row.
  • Use a moving selection surface to connect the old and new choice.

When not to use

  • Space permits only one compact action. Use Icon Theme Toggle.
  • People need a longer list of named choices. Use Theme Selector.

Installation

Set up uitheme-web in an existing Tailwind app. Then install the component. The CLI copies one component file and reuses the shared cn utility.

Tailwind version
Source language
npx uitheme-cli add theme-switcher --base reka --tailwind 4 --language ts

Usage

Use the control inside your theme provider. Import it from your app’s component directory.

example.vue
<script setup lang="ts">
import { UIThemeProvider } from 'uitheme-web/vue';
import ThemeSwitcher from '@/components/ui/theme-switcher.vue';
</script>

<template>
  <UIThemeProvider>
    <ThemeSwitcher />
  </UIThemeProvider>
</template>

API reference

These props belong to the installed control. Its page transition settings override the provider for this action only.

PropTypeBehavior
labelstringOverride the readable accessible label.
borderStyle'solid' | 'shadow'Use the default solid border or prominent shadow borders. Both use the same space and theme tokens.
classstringExtend the root styles. Your classes replace default classes through the local merge helper.
classesobjectStyle root, item, and selection indicator parts.
disabledbooleanDisable interaction. Preference controls also wait for the provider to mount.
animationTypeThemeAnimationTypeOverride the UI theme transition for this control. Defaults to the provider setting.
durationnumberUI theme transition duration in milliseconds. Defaults to the provider setting.
slideDirectionSlideDirectionValid only with animationType set to ThemeAnimationType.SLIDE. Other animation types do not accept this prop.

Keyboard

KeyAction
TabEnter or leave the radio group.
Arrow keysMove and select among enabled choices.
SpaceSelect the focused radio.

Accessibility

  • Keep the accessible label when changing the visible content. Icons are decorative.
  • The control stays disabled until the theme provider mounts.
  • Reduced motion removes travel and rotation. Icon state keeps a short opacity change. The selection surface changes position without travel.
  • Keyboard activation changes the theme without the pointer animation.

Theme behavior

The checked radio follows the stored theme preference, including system. The selected surface moves between the three equal tracks. The icons brighten on fine-pointer hover. The group disables choices that the provider does not support.

AI Agent notes

  • Use uitheme-web/react for React or uitheme-web/vue for Vue. Keep the theme provider around the control.
  • Keep page transitions in the control’s animationType and duration props. Do not add a standalone transition-settings control.
  • Keep input labels, focus styles, selected state, disabled state, and reduced-motion behavior when editing source.
  • Use the shared theme tokens. Do not hardcode a palette or radius.

Source

The CLI reuses your shared cn utility. If it is missing, installation creates it at the configured utils path. Existing utilities are checked before a replacement is offered.

These are the files the CLI installs. The control installs one main component file.

<script setup lang="ts">
import type { ThemeControlTransitionProps } from 'uitheme-web/vue';
import { useId, shallowRef } from 'vue';
import { useUITheme, type Theme } from 'uitheme-web/vue';
import { SunIcon, MoonIcon, MonitorIcon } from '@lucide/vue';
import { cn } from '@/lib/utils';

const props = withDefaults(
  defineProps<
    ThemeControlTransitionProps & {
      label?: string;
      disabled?: boolean;
      borderStyle?: 'solid' | 'shadow';
      class?: string;
      classes?: {
        root?: string;
        item?: string;
        indicator?: string;
      };
    }
  >(),
  {
    duration: undefined,
    slideDirection: undefined,
    borderStyle: 'solid',
    class: undefined,
    classes: undefined,
    label: 'Theme',
  }
);

const theme = useUITheme(() => ({
  animationType: props.animationType,
  duration: props.duration,
  slideDirection: props.slideDirection,
}));

const choices: readonly Theme[] = ['system', 'light', 'dark'];

const id = useId();

const keyboard = shallowRef(false);
</script>
<template>
  <div
    :data-border-style="borderStyle"
    role="radiogroup"
    :aria-label="label"
    :class="
      cn(
        'relative isolate inline-grid h-8 shrink-0 grid-cols-3 items-stretch rounded-[var(--radius)] border border-border bg-muted p-[var(--switcher-pad)] [--switcher-pad:calc(var(--spacing,0.25rem)/2)] [--switcher-inner-radius:max(0px,calc(var(--radius)-var(--switcher-pad)-1px))] [@media(pointer:coarse)]:h-11',
        cn(
          'data-[border-style=shadow]:[--shadow-border-edge:theme(colors.border)] data-[border-style=shadow]:[--shadow-border-focus:theme(colors.ring)] data-[border-style=shadow]:border-transparent data-[border-style=shadow]:focus-visible:border-transparent data-[border-style=shadow]:ring-0 data-[border-style=shadow]:focus-visible:ring-0 data-[border-style=shadow]:shadow-[0_0_4px_rgb(0_0_0_/_0.25),inset_0_0_0_1px_var(--shadow-border-edge)] data-[border-style=shadow]:forced-colors:outline data-[border-style=shadow]:forced-colors:outline-1 data-[border-style=shadow]:forced-colors:outline-[CanvasText] data-[border-style=shadow]:forced-colors:focus-visible:outline-2 data-[border-style=shadow]:forced-colors:focus-visible:outline-[Highlight]',
          cn(props.classes?.root ?? '', props.class)
        )
      )
    "
    @keydown.capture="keyboard = true"
    @pointerdown.capture="keyboard = false"
  >
    <span
      aria-hidden="true"
      data-slot="selection-indicator"
      :style="{
        transform: `translateX(${choices.indexOf(theme.theme.value) * 100}%)`,
      }"
      :class="
        cn(
          'pointer-events-none absolute bottom-[var(--switcher-pad)] left-[var(--switcher-pad)] top-[var(--switcher-pad)] -z-10 w-[calc((100%_-_var(--switcher-pad)_-_var(--switcher-pad))/3)] rounded-[var(--switcher-inner-radius)] bg-card border border-border motion-reduce:transition-none',
          cn(
            keyboard
              ? 'transition-none'
              : 'transition-transform duration-200 ease-[cubic-bezier(0.22,1,0.36,1)]',
            props.classes?.indicator
          )
        )
      "
    />
    <label
      v-for="value in choices"
      :key="value"
      :data-checked="theme.theme.value === value ? '' : undefined"
      :class="
        cn(
          'relative flex min-w-8 cursor-pointer items-center justify-center rounded-[var(--switcher-inner-radius)] px-2 text-muted-foreground has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50 has-[:focus-visible]:ring-[3px] has-[:focus-visible]:ring-ring/50 data-[checked]:text-foreground [@media(hover:hover)_and_(pointer:fine)]:hover:text-foreground [@media(pointer:coarse)]:min-w-11',
          cn(
            keyboard
              ? 'transition-none'
              : 'transition-colors duration-150 ease-[cubic-bezier(0.22,1,0.36,1)]',
            props.classes?.item
          )
        )
      "
    >
      <input
        type="radio"
        :name="id"
        :value="value"
        :checked="theme.theme.value === value"
        :aria-label="`${value[0]!.toUpperCase() + value.slice(1)} theme`"
        :disabled="
          disabled || !theme.mounted.value || !theme.themes.includes(value)
        "
        class="peer absolute inset-0 z-10 m-0 size-full cursor-inherit opacity-0"
        @change="
          theme.switchTheme(value, {
            animationOff: keyboard,
            element: ($event.currentTarget as HTMLElement).closest('label'),
          })
        "
      />
      <component
        :is="
          value === 'light'
            ? SunIcon
            : value === 'dark'
              ? MoonIcon
              : MonitorIcon
        "
        aria-hidden="true"
        class="size-4 shrink-0"
      />
    </label>
  </div>
</template>

Dependencies: @lucide/vue

Customization

Use borderStyle="shadow" in React or border-style="shadow" in Vue for prominent shadow borders. The default is solid.

Use slideDirection only when animationType is ThemeAnimationType.SLIDE. Set both props on the control when you need a slide direction override.