Skip to content

Instantly share code, notes, and snippets.

@tommie
Last active February 28, 2026 07:56
Show Gist options
  • Select an option

  • Save tommie/819457f75a950dfb975e4694524a91ba to your computer and use it in GitHub Desktop.

Select an option

Save tommie/819457f75a950dfb975e4694524a91ba to your computer and use it in GitHub Desktop.
Nuxt color-mode v3 workaround for SSR preference
// Writes the resolved color mode value (dark/light) to a cookie so the
// server can use it during SSR. The built-in color-mode module only stores
// the preference ("system", "dark", "light"), but when preference is
// "system" the server can't resolve it — it doesn't have a media query.
// This cookie bridges that gap.
const COOKIE_NAME = 'nuxt-color-mode-value'
const PREF_COOKIE_NAME = 'nuxt-color-mode'
const MAX_AGE = 365 * 24 * 60 * 60 // 1 year in seconds
function setCookie(name: string, value: string) {
document.cookie = `${name}=${value}; path=/; max-age=${MAX_AGE}; SameSite=Lax`
}
export default defineNuxtPlugin(() => {
const colorMode = useColorMode()
watch(() => colorMode.value, (value) => {
setCookie(COOKIE_NAME, value)
}, { immediate: true })
// TODO: Remove once Nuxt UI supports @nuxtjs/color-mode v4, which
// has cookieAttrs. v3 sets a session cookie with no max-age.
watch(() => colorMode.preference, (pref) => {
setCookie(PREF_COOKIE_NAME, pref)
}, { immediate: true })
})
// Reads the resolved color mode value from the cookie written by the
// client plugin. When preference is "system", the built-in color-mode
// module can't resolve it on the server — there's no media query.
// This plugin reads the resolved value and applies the CSS class to
// <html> during SSR so hydration matches the client.
//
// User plugins run after module plugins (like color-mode), so the
// colorMode state is already initialized when this runs.
const COOKIE_NAME = 'nuxt-color-mode-value'
export default defineNuxtPlugin(() => {
const colorMode = useColorMode()
const cookie = useCookie(COOKIE_NAME)
if (cookie.value && colorMode.unknown) {
colorMode.value = cookie.value
// The color-mode module's htmlAttrs object isn't reactive, so
// setting colorMode.value alone doesn't add the class to <html>.
useHead({
htmlAttrs: {
class: cookie.value
}
})
}
})
<script setup lang="ts">
// Cycles through system → light → system → dark → system → …
// Shows the system icon (monitor) when preference is "system", with a
// small sun/moon badge indicating the resolved value.
const colorMode = useColorMode()
// The cycle: system → light → system → dark → system → …
// We track position explicitly because "system" appears twice and
// indexOf would always find the first occurrence.
const cycle = ['system', 'light', 'system', 'dark'] as const
let pos = 0
function next() {
pos = (pos + 1) % cycle.length
colorMode.preference = cycle[pos] ?? 'system'
}
const icon = computed(() => {
switch (colorMode.preference) {
case 'light': return 'i-lucide-sun'
case 'dark': return 'i-lucide-moon'
default: return 'i-lucide-monitor'
}
})
const badgeIcon = computed(() => {
if (colorMode.preference !== 'system') return null
return colorMode.value === 'dark' ? 'i-lucide-moon' : 'i-lucide-sun'
})
const label = computed(() => {
switch (colorMode.preference) {
case 'light': return 'Switch to system'
case 'dark': return 'Switch to system'
default: return colorMode.value === 'dark' ? 'Switch to light' : 'Switch to dark'
}
})
</script>
<template>
<UButton
color="neutral"
variant="ghost"
:aria-label="label"
square
@click="next"
>
<template #leading>
<span class="relative">
<UIcon
:name="icon"
class="size-5"
/>
<UIcon
v-if="badgeIcon"
:name="badgeIcon"
class="absolute -bottom-1 -right-1.5 size-2.5"
/>
</span>
</template>
</UButton>
</template>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment