Skip to content

Theme Toggle ​

A sun/moon toggle with a rotating swap animation.

Installation ​

pnpm dlx shadcn-vue@latest add https://vuelix.dev/r/theme-toggle.json
Manual installation

Install the dependencies:

bash
npm install motion-v @vueuse/core

Then copy these files into components/ui/theme-toggle/ (plus lib/utils.ts if you don't have it):

vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { useDark, useToggle } from '@vueuse/core'
import { AnimatePresence, motion } from 'motion-v'
import { onMounted, ref } from 'vue'
import { cn } from '@/lib/utils'

const props = withDefaults(defineProps<{
  /** localStorage key; match your framework's key to stay in sync (VitePress: `vitepress-theme-appearance`). */
  storageKey?: string
  class?: HTMLAttributes['class']
}>(), {
  storageKey: 'vuelix-color-scheme',
})

const isDark = useDark({ storageKey: props.storageKey })
const toggle = useToggle(isDark)

// The server can't know the user's theme, so the theme-dependent icon and label render only
// after mount (otherwise dark-mode users get a hydration mismatch).
const mounted = ref(false)
onMounted(() => (mounted.value = true))

const spring = { type: 'spring', stiffness: 380, damping: 22 } as const
</script>

<template>
  <motion.button
    type="button"
    :aria-label="!mounted ? 'Toggle theme' : isDark ? 'Switch to light theme' : 'Switch to dark theme'"
    :class="cn(
      'relative inline-flex size-10 items-center justify-center overflow-hidden rounded-full border bg-background text-foreground outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50',
      props.class,
    )"
    :while-press="{ scale: 0.9 }"
    :transition="spring"
    @click="toggle()"
  >
    <AnimatePresence v-if="mounted" mode="popLayout" :initial="false">
      <motion.svg
        v-if="isDark"
        key="moon"
        class="size-5"
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        stroke-width="2"
        stroke-linecap="round"
        stroke-linejoin="round"
        aria-hidden="true"
        :initial="{ y: 24, rotate: -90, opacity: 0 }"
        :animate="{ y: 0, rotate: 0, opacity: 1 }"
        :exit="{ y: -24, rotate: 90, opacity: 0 }"
        :transition="spring"
      >
        <path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401" />
      </motion.svg>
      <motion.svg
        v-else
        key="sun"
        class="size-5"
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        stroke-width="2"
        stroke-linecap="round"
        stroke-linejoin="round"
        aria-hidden="true"
        :initial="{ y: 24, rotate: 90, opacity: 0 }"
        :animate="{ y: 0, rotate: 0, opacity: 1 }"
        :exit="{ y: -24, rotate: -90, opacity: 0 }"
        :transition="spring"
      >
        <circle cx="12" cy="12" r="4" />
        <path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
      </motion.svg>
    </AnimatePresence>
  </motion.button>
</template>
ts
export { default as ThemeToggle } from './ThemeToggle.vue'

Usage ​

ts
import { ThemeToggle } from '@/components/ui/theme-toggle'
vue
<ThemeToggle />

API ​

PropTypeDefaultDescription
storageKeystring'vuelix-color-scheme'localStorage key used by useDark.

Toggles the dark class on <html> via @vueuse/core. Point storageKey at your framework's key (e.g. nuxt-color-mode) to stay in sync.

Designed by Kuldeep Pisda · Released under the MIT License