Theme Toggler Button

A button that cycles the theme, its icon turning into the next one.

<script setup lang="ts">
import type { VariantProps } from 'class-variance-authority'
import type { ThemeSelection, themeTogglerButtonVariants } from '@/components/animate-ui/buttons/theme-toggler'
import { ComputerIcon, Moon02Icon, Sun03Icon } from '@hugeicons/core-free-icons'
import { HugeiconsIcon } from '@hugeicons/vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { ThemeTogglerButton, ThemeTogglerButtonIcon } from '@/components/animate-ui/buttons/theme-toggler'

type Variants = VariantProps<typeof themeTogglerButtonVariants>

const props = withDefaults(
  defineProps<{ variant?: Variants['variant'], size?: Variants['size'], system?: boolean | string }>(),
  { variant: 'default', size: 'default', system: false },
)

// The site's theme (next-themes): read from its storage and the page's class.
const theme = ref<ThemeSelection>('system')
const resolvedTheme = ref<'light' | 'dark'>('light')
const read = () => {
  theme.value = (localStorage.getItem('theme') as ThemeSelection | null) ?? 'system'
  resolvedTheme.value = document.documentElement.classList.contains('dark') ? 'dark' : 'light'
}
const observer = new MutationObserver(read)
onMounted(() => {
  read()
  observer.observe(document.documentElement, { attributeFilter: ['class'] })
})
onBeforeUnmount(() => observer.disconnect())

function setTheme(next: ThemeSelection) {
  localStorage.setItem('theme', next)
  window.dispatchEvent(new StorageEvent('storage', { key: 'theme', newValue: next }))
  read()
}

const withSystem = computed(() => props.system === true || props.system === 'true')
</script>

<template>
  <ThemeTogglerButton
    :theme="theme"
    :resolved-theme="resolvedTheme"
    :set-theme="setTheme"
    :modes="withSystem ? ['light', 'dark', 'system'] : ['light', 'dark']"
    aria-label="Toggle theme"
    :variant="variant"
    :size="size"
  >
    <ThemeTogglerButtonIcon>
      <template #light>
        <HugeiconsIcon :icon="Sun03Icon" :stroke-width="2" />
      </template>
      <template #dark>
        <HugeiconsIcon :icon="Moon02Icon" :stroke-width="2" />
      </template>
      <template #system>
        <HugeiconsIcon :icon="ComputerIcon" :stroke-width="2" />
      </template>
    </ThemeTogglerButtonIcon>
  </ThemeTogglerButton>
</template>

Installation

npx shadcn-vue@latest add https://animate-ui.com/r/vue/buttons-theme-toggler.json
pnpm dlx shadcn-vue@latest add https://animate-ui.com/r/vue/buttons-theme-toggler.json
yarn dlx shadcn-vue@latest add https://animate-ui.com/r/vue/buttons-theme-toggler.json
bun x shadcn-vue@latest add https://animate-ui.com/r/vue/buttons-theme-toggler.json

Usage

<script setup lang="ts">
import { useColorMode } from '@vueuse/core';
import {
  ThemeTogglerButton,
  ThemeTogglerButtonIcon,
  type Resolved,
  type ThemeSelection,
} from '@/components/animate-ui/buttons/theme-toggler';

const { store, state } = useColorMode();
</script>

<template>
  <ThemeTogglerButton
    :theme="store === 'auto' ? 'system' : (store as ThemeSelection)"
    :resolved-theme="state as Resolved"
    :set-theme="(theme) => (store = theme === 'system' ? 'auto' : theme)"
    :modes="['light', 'dark']"
    aria-label="Toggle theme"
  >
    <ThemeTogglerButtonIcon>
      <template #light><SunIcon /></template>
      <template #dark><MoonIcon /></template>
    </ThemeTogglerButtonIcon>
  </ThemeTogglerButton>
</template>

Each click applies the next theme of modes at once, through setTheme. For a wipe across the page, use Theme Toggler. When modes leave out system, the button shows the theme actually displayed, and a system theme counts as the one it resolves to. Read the state in your own parts with useThemeTogglerButton().

Animation

  • The icon swaps as the theme changes: the old one turns 90° on as it shrinks to half its size into a light blur (blur(2px)) and fades out, while the new one turns in from 90° back and grows out of it, so both keep turning the same way. Over 0.3s with an ease-out (cubic-bezier(0.23, 1, 0.32, 1)), twice the Copy Button's swap, so the turn reads. Icons only swap once the button is used: on mount, and when the theme becomes known after hydrating, the icon shows at rest.
  • Pressing scales the button to tap-scale (0.97), on Motion's spring for scale (stiffness: 550, damping: 30). It doesn't scale on hover, unless you set hover-scale.
  • <AnimateUIProvider :tempo> paces the swap and the press spring.
  • Under reduced motion, the icons crossfade through their blur without turning or scaling, and the button doesn't scale.

API Reference

ThemeTogglerButton

PropTypeDefault
variant?
'default' | 'accent' | 'outline' | 'ghost' | null'default'
size?
'default' | 'sm' | 'lg' | null'default'
theme
'light' | 'dark' | 'system'-
resolved-theme
'light' | 'dark'-
set-theme
(theme: 'light' | 'dark' | 'system') => void-
modes?
('light' | 'dark' | 'system')[]['light', 'dark', 'system']
hover-scale?
number1
tap-scale?
number0.97
transition?
Transition{ type: 'spring', stiffness: 550, damping: 30, restSpeed: 10 }
as-child?
booleanfalse
...props?
HTMLMotionProps<'button'>-

ThemeTogglerButtonIcon

Its slots #light and #dark hold the icons of each theme; #system, the icon of the system theme (without it, system shows the icon of the theme it resolves to).

PropTypeDefault
rotate?
number90
transition?
Transition{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }
...props?
HTMLAttributes-

Data attributes

PropTypeDefault
data-theme?
'light' | 'dark' | 'system'-

Built by Skyleen. The source code is available on GitHub.

Last updated: 10/23/2026