UIPackage

Theme Switch

Vue action
Edit on GitHub

Light / dark / system theme toggle — drop in the header. Seven visual variants: `cards`, `icons`, `icon-only`, `dropdown`, `pill`, `pill-4`, and `switch`. Persists choice to `localStorage` and respects `prefers-color-scheme` for `system`.

Also available for React ->

Installation

$ npx shadcn-vue@latest add https://uipkge.dev/r/vue/theme-switch.json

Or with the named registry: npx shadcn-vue@latest add @uipkge/theme-switch

Examples

Props

Name Type / Values Default Required
modelValue Theme required
variant Variant 'cards' optional
title string optional
description string optional
class string optional

Dependencies

Used by

Files (2)

  • app/components/ui/theme-switch/ThemeSwitch.vue 8.1 kB
    <script setup lang="ts">
    import { computed } from 'vue'
    import { ChevronDown, Monitor, Moon, Palette, Sparkles, Sun } from 'lucide-vue-next'
    import { SectionCard } from '@/components/ui/section-card'
    import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
    
    type Theme = 'light' | 'dark' | 'system' | 'black'
    type Variant = 'cards' | 'icons' | 'icon-only' | 'dropdown' | 'pill' | 'pill-4' | 'switch'
    
    const ICONS: Record<Theme, any> = {
      light: Sun,
      dark: Moon,
      system: Monitor,
      black: Sparkles,
    }
    
    const LABELS: Record<Theme, string> = {
      light: 'Light',
      dark: 'Dark',
      system: 'System',
      black: 'Black',
    }
    
    const VARIANT_OPTIONS: Record<Variant, Theme[]> = {
      cards: ['light', 'dark', 'system'],
      icons: ['light', 'dark', 'system'],
      'icon-only': ['light', 'dark'],
      dropdown: ['light', 'dark', 'system'],
      pill: ['light', 'dark', 'system'],
      'pill-4': ['system', 'light', 'dark', 'black'],
      switch: ['light', 'dark'],
    }
    
    const props = withDefaults(
      defineProps<{
        modelValue: Theme
        variant?: Variant
        title?: string
        description?: string
        class?: string
      }>(),
      { variant: 'cards' },
    )
    
    const emit = defineEmits<{ 'update:modelValue': [Theme] }>()
    
    const options = computed(() => VARIANT_OPTIONS[props.variant])
    const activeIndex = computed(() => {
      const i = options.value.indexOf(props.modelValue)
      return i === -1 ? 0 : i
    })
    
    const indicatorStyle = computed(() => ({
      width: `calc((100% - 4px) / ${options.value.length})`,
      transform: `translateX(calc(${activeIndex.value} * 100%))`,
    }))
    
    function set(t: Theme) {
      emit('update:modelValue', t)
    }
    function cycle() {
      const next = options.value[(activeIndex.value + 1) % options.value.length]
      if (next) emit('update:modelValue', next)
    }
    </script>
    
    <template>
      <!-- Cards: full SectionCard with 3-button grid (default) -->
      <SectionCard
        v-if="variant === 'cards'"
        :title="title ?? 'Appearance'"
        :description="description ?? 'Choose your interface theme.'"
        :class="$props.class"
      >
        <template #header-action>
          <Palette class="text-muted-foreground size-5" />
        </template>
        <div class="grid grid-cols-3 gap-2">
          <button
            type="button"
            v-for="t in options"
            :key="t"
            class="focus-visible:ring-ring rounded-md border p-3 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:outline-none"
            :class="
              modelValue === t ? 'border-primary ring-primary bg-primary/5 ring-1' : 'border-border hover:bg-muted/50'
            "
            @click="set(t)"
          >
            <component :is="ICONS[t]" class="text-muted-foreground mb-2 size-4" aria-hidden="true" />
            <p class="text-xs font-medium">{{ LABELS[t] }}</p>
          </button>
        </div>
      </SectionCard>
    
      <!-- Icons: compact 3-icon segmented row, no labels -->
      <div
        v-else-if="variant === 'icons'"
        role="radiogroup"
        :aria-label="title ?? 'Theme'"
        :class="['border-border bg-card inline-flex items-center gap-0.5 rounded-md border p-0.5', $props.class]"
      >
        <button
          type="button"
          v-for="t in options"
          :key="t"
          role="radio"
          :aria-checked="modelValue === t"
          :aria-label="LABELS[t]"
          class="focus-visible:ring-ring grid size-7 place-items-center rounded transition-colors focus-visible:ring-2 focus-visible:outline-none"
          :class="
            modelValue === t
              ? 'bg-primary text-primary-foreground'
              : 'text-muted-foreground hover:bg-muted hover:text-foreground'
          "
          @click="set(t)"
        >
          <component :is="ICONS[t]" class="size-4" aria-hidden="true" />
        </button>
      </div>
    
      <!-- Icon-only: header-grade icon button. No border, ghost background,
           rounded-lg to match adjacent header affordances (notification
           bell, profile avatar). The previous `rounded-full border bg-card`
           coin-shape stood out from typical icon-button row layouts; if you
           need that look, pass `class="rounded-full border border-border bg-card"`
           and it'll override.
    
           Dropped the rotate+fade Transition that the previous version
           wrapped the icon in. `mode="out-in"` with opacity-0 on both the
           enter-from and leave-to states left a frame where the button was
           empty -- visible as a flicker that occasionally rendered as "no
           icon at all" on slower devices. A direct swap reads cleaner. -->
      <button
        type="button"
        v-else-if="variant === 'icon-only'"
        :aria-label="LABELS[modelValue]"
        :class="[
          'text-muted-foreground hover:text-foreground hover:bg-accent focus-visible:ring-ring inline-flex size-8 items-center justify-center rounded-lg transition-colors focus-visible:ring-2 focus-visible:outline-none',
          $props.class,
        ]"
        @click="cycle"
      >
        <component :is="ICONS[modelValue]" class="size-4" aria-hidden="true" />
      </button>
    
      <!-- Dropdown: trigger button → menu of states -->
      <DropdownMenu v-else-if="variant === 'dropdown'">
        <DropdownMenuTrigger as-child>
          <button
            type="button"
            :class="[
              'border-border bg-card hover:bg-muted focus-visible:ring-ring inline-flex h-9 items-center gap-2 rounded-md border px-3 text-sm transition focus-visible:ring-2 focus-visible:outline-none',
              $props.class,
            ]"
          >
            <component :is="ICONS[modelValue]" class="size-4" aria-hidden="true" />
            <span>{{ LABELS[modelValue] }}</span>
            <ChevronDown class="size-3 opacity-60" aria-hidden="true" />
          </button>
        </DropdownMenuTrigger>
        <DropdownMenuContent align="end" class="min-w-[140px]">
          <DropdownMenuItem v-for="t in options" :key="t" @click="set(t)">
            <component :is="ICONS[t]" class="mr-2 size-4" aria-hidden="true" />
            <span>{{ LABELS[t] }}</span>
          </DropdownMenuItem>
        </DropdownMenuContent>
      </DropdownMenu>
    
      <!-- Pill / Pill-4: equal segments with sliding indicator -->
      <div
        v-else-if="variant === 'pill' || variant === 'pill-4'"
        role="radiogroup"
        :aria-label="title ?? 'Theme'"
        :class="['border-border bg-card relative inline-flex w-full max-w-md rounded-full border p-0.5', $props.class]"
      >
        <span
          aria-hidden
          class="bg-primary pointer-events-none absolute top-0.5 bottom-0.5 left-0.5 rounded-full transition-transform duration-300 ease-out"
          :style="indicatorStyle"
        />
        <button
          type="button"
          v-for="t in options"
          :key="t"
          role="radio"
          :aria-checked="modelValue === t"
          :aria-label="LABELS[t]"
          class="focus-visible:ring-ring relative z-[1] inline-flex h-7 flex-1 items-center justify-center gap-1.5 rounded-full px-3 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none"
          :class="modelValue === t ? 'text-primary-foreground' : 'text-muted-foreground hover:text-foreground'"
          @click="set(t)"
        >
          <component :is="ICONS[t]" class="size-3.5" aria-hidden="true" />
          <span>{{ LABELS[t] }}</span>
        </button>
      </div>
    
      <!-- Switch: iOS-style 2-state toggle with thumb that slides -->
      <button
        type="button"
        v-else-if="variant === 'switch'"
        role="switch"
        :aria-checked="modelValue === 'dark'"
        :aria-label="LABELS[modelValue]"
        :class="[
          'border-border focus-visible:ring-ring relative inline-flex h-8 w-16 items-center rounded-full border transition-colors focus-visible:ring-2 focus-visible:outline-none',
          modelValue === 'dark' ? 'bg-zinc-900' : 'bg-amber-100',
          $props.class,
        ]"
        @click="set(modelValue === 'dark' ? 'light' : 'dark')"
      >
        <Sun
          class="absolute left-1.5 size-4 text-amber-500 transition-opacity"
          :class="modelValue === 'dark' ? 'opacity-30' : 'opacity-100'"
          aria-hidden="true"
        />
        <Moon
          class="absolute right-1.5 size-4 text-zinc-300 transition-opacity"
          :class="modelValue === 'light' ? 'opacity-30' : 'opacity-100'"
          aria-hidden="true"
        />
        <span
          aria-hidden
          class="bg-card border-border absolute size-6 rounded-full border shadow transition-transform duration-300 ease-out"
          :style="{ transform: `translateX(${modelValue === 'dark' ? '36px' : '4px'})` }"
        />
      </button>
    </template>
  • app/components/ui/theme-switch/index.ts 0.1 kB
    export { default as ThemeSwitch } from './ThemeSwitch.vue'

Raw manifest: https://uipkge.dev/r/vue/theme-switch.json