Theme Toggler

A theme switch that wipes the new theme across the page with the View Transitions API.

<script setup lang="ts">
import type { Direction, ThemeSelection } from '@animate-ui/vue/primitives/effects/theme-toggler'
import { ComputerIcon, Moon02Icon, Sun03Icon } from '@hugeicons/core-free-icons'
import { HugeiconsIcon } from '@hugeicons/vue'
import { ThemeToggler } from '@animate-ui/vue/primitives/effects/theme-toggler'
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { Button } from '@/components/ui/button'

withDefaults(defineProps<{ direction?: Direction }>(), { direction: 'ltr' })

const NEXT: Record<ThemeSelection, ThemeSelection> = {
  light: 'system',
  system: 'dark',
  dark: 'light',
}
const ICONS = { light: Sun03Icon, dark: Moon02Icon, system: ComputerIcon }

// 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()
}
</script>

<template>
  <ThemeToggler
    :theme="theme"
    :resolved-theme="resolvedTheme"
    :set-theme="setTheme"
    :direction="direction"
  >
    <template #default="{ effective, toggleTheme }">
      <Button
        variant="outline"
        size="icon-lg"
        :aria-label="`Theme: ${effective}. Switch to ${NEXT[effective as ThemeSelection]}`"
        @click="toggleTheme(NEXT[effective as ThemeSelection])"
      >
        <HugeiconsIcon :icon="ICONS[effective as ThemeSelection]" :stroke-width="2" />
      </Button>
    </template>
  </ThemeToggler>
</template>

Installation

npm install @animate-ui/vue motion-v
pnpm add @animate-ui/vue motion-v
yarn add @animate-ui/vue motion-v
bun add @animate-ui/vue motion-v

Usage

<script setup lang="ts">
import { useColorMode } from '@vueuse/core';
import {
  ThemeToggler,
  type ThemeSelection,
} from '@animate-ui/vue/primitives/effects/theme-toggler';

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

<template>
  <ThemeToggler
    :theme="store as ThemeSelection"
    :resolved-theme="system === 'dark' || store === 'dark' ? 'dark' : 'light'"
    :set-theme="(theme) => (store = theme === 'system' ? 'auto' : theme)"
  >
    <template #default="{ effective, toggleTheme }">
      <button @click="toggleTheme(effective === 'dark' ? 'light' : 'dark')">
        {{ effective === 'dark' ? 'Light mode' : 'Dark mode' }}
      </button>
    </template>
  </ThemeToggler>
</template>

During the wipe, the dark class is toggled on <html>: this matches class-based dark mode (Tailwind's default, VueUse's useColorMode).

Animation

  • The new theme is revealed with a clip-path wipe over 700ms with an ease-in-out, in direction (left to right by default). The old and new pages are snapshots, so nothing re-renders mid-wipe.
  • The whole page is wiped at once: elements named for other view transitions (a header kept across pages) are wiped with it, not crossfaded on their own.
  • set-theme is called once the wipe finishes; @immediate-change fires right away.
  • Switching to system when it resolves to the current theme changes nothing on screen, so there is no wipe.
  • Browsers without the View Transitions API swap the theme at once.
  • <AnimateUIProvider :tempo> paces the wipe.
  • Under reduced motion, the theme swaps at once, without the wipe.
  • On the server and while hydrating, the render function receives effective: 'system' and resolved: 'light', since the theme is only known on the client.

API Reference

ThemeToggler

PropTypeDefault
theme
'light' | 'dark' | 'system'-
resolved-theme
'light' | 'dark'-
set-theme
(theme: 'light' | 'dark' | 'system') => void-
direction?
'btt' | 'ttb' | 'ltr' | 'rtl''ltr'
on-immediate-change?
(theme: 'light' | 'dark' | 'system') => void-
#default?
slot, with { resolved: 'light' | 'dark'; effective: 'light' | 'dark' | 'system'; toggleTheme: (theme: 'light' | 'dark' | 'system') => void }-

Credits

  • Inspired by Magic UI's animated theme toggler.

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

Last updated: 10/23/2026