A complete dark/light/system theme switcher for Next.js (App Router) with an animated sliding indicator.
pnpm add next-themes motion lucide-react clsximport { ThemeProvider } from 'next-themes'
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
// suppressHydrationWarning prevents mismatch warning from the injected class
<html lang="en" suppressHydrationWarning>
<body className="bg-white font-sans tracking-tight antialiased dark:bg-zinc-950">
<ThemeProvider
enableSystem={true}
attribute="class"
storageKey="theme"
defaultTheme="system"
>
{children}
</ThemeProvider>
</body>
</html>
)
}attribute="class"— toggles.darkon<html>defaultTheme="system"— respects OS preference out of the boxstorageKey="theme"— persists choice tolocalStorage
@import 'tailwindcss';
@custom-variant dark (&:is(.dark *));This is the Tailwind v4 way to enable class-based dark mode. dark: utilities apply when an ancestor has the .dark class.
'use client'
import { AnimatedBackground } from '@/components/ui/animated-background'
import { MonitorIcon, MoonIcon, SunIcon } from 'lucide-react'
import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react'
const THEMES_OPTIONS = [
{
label: 'Light',
id: 'light',
icon: <SunIcon className="h-4 w-4" />,
},
{
label: 'Dark',
id: 'dark',
icon: <MoonIcon className="h-4 w-4" />,
},
{
label: 'System',
id: 'system',
icon: <MonitorIcon className="h-4 w-4" />,
},
]
export function ThemeSwitch() {
const [mounted, setMounted] = useState(false)
const { theme, setTheme } = useTheme()
// Prevent hydration mismatch — render nothing on the server
useEffect(() => {
setMounted(true)
}, [])
if (!mounted) {
return null
}
return (
<AnimatedBackground
className="pointer-events-none rounded-lg bg-zinc-100 dark:bg-zinc-800"
defaultValue={theme}
transition={{
type: 'spring',
bounce: 0,
duration: 0.2,
}}
enableHover={false}
onValueChange={(id) => {
setTheme(id as string)
}}
>
{THEMES_OPTIONS.map((theme) => {
return (
<button
key={theme.id}
className="inline-flex h-7 w-7 items-center justify-center text-zinc-500 transition-colors duration-100 focus-visible:outline-2 data-[checked=true]:text-zinc-950 dark:text-zinc-400 dark:data-[checked=true]:text-zinc-50"
type="button"
aria-label={`Switch to ${theme.label} theme`}
data-id={theme.id}
>
{theme.icon}
</button>
)
})}
</AnimatedBackground>
)
}Uses Motion's layoutId to create a sliding pill indicator between the three buttons.
'use client'
import { cn } from '@/lib/utils'
import * as motion from 'motion/react-m'
import { AnimatePresence, Transition } from 'motion/react'
import {
Children,
cloneElement,
ReactElement,
useEffect,
useState,
useId,
} from 'react'
export type AnimatedBackgroundProps = {
children:
| ReactElement<{ 'data-id': string }>[]
| ReactElement<{ 'data-id': string }>
defaultValue?: string
onValueChange?: (newActiveId: string | null) => void
className?: string
transition?: Transition
enableHover?: boolean
}
export function AnimatedBackground({
children,
defaultValue,
onValueChange,
className,
transition,
enableHover = false,
}: AnimatedBackgroundProps) {
const [activeId, setActiveId] = useState<string | null>(null)
const uniqueId = useId()
const handleSetActiveId = (id: string | null) => {
setActiveId(id)
if (onValueChange) {
onValueChange(id)
}
}
useEffect(() => {
if (defaultValue !== undefined) {
setActiveId(defaultValue)
}
}, [defaultValue])
return Children.map(children, (child: any, index) => {
const id = child.props['data-id']
const interactionProps = enableHover
? {
onMouseEnter: () => handleSetActiveId(id),
onMouseLeave: () => handleSetActiveId(null),
}
: {
onClick: () => handleSetActiveId(id),
}
return cloneElement(
child,
{
key: index,
className: cn('relative inline-flex', child.props.className),
'data-checked': activeId === id ? 'true' : 'false',
...interactionProps,
},
<>
<AnimatePresence initial={false}>
{activeId === id && (
<motion.div
layoutId={`background-${uniqueId}`}
className={cn('absolute inset-0', className)}
transition={transition}
initial={{ opacity: defaultValue ? 1 : 0 }}
animate={{
opacity: 1,
}}
exit={{
opacity: 0,
}}
/>
)}
</AnimatePresence>
<div className="z-10">{child.props.children}</div>
</>,
)
})
}Required wrapper for motion/react-m components (LazyMotion reduces bundle from ~34kb to ~6kb).
'use client'
import { LazyMotion } from 'motion/react'
const loadFeatures = () =>
import('@/lib/motion-features').then((res) => res.default)
export function MotionProvider({ children }: { children: React.ReactNode }) {
return (
<LazyMotion features={loadFeatures} strict>
{children}
</LazyMotion>
)
}// lib/motion-features.ts
import { domMax } from 'motion/react'
export default domMaximport { ClassValue, clsx } from 'clsx'
export function cn(...inputs: ClassValue[]) {
return clsx(inputs)
}Wrap with MotionProvider and drop ThemeSwitch wherever you want.
import { MotionProvider } from './motion-provider'
import { ThemeSwitch } from './theme-switch'
export function Footer() {
return (
<MotionProvider>
<footer className="mt-24 border-t border-zinc-100 px-0 py-4 dark:border-zinc-800">
<div className="flex items-center justify-between">
{/* your content */}
<div className="text-xs text-zinc-400">
<ThemeSwitch />
</div>
</div>
</footer>
</MotionProvider>
)
}next-themeshandles system preference detection (prefers-color-scheme), localStorage persistence, and toggling the.darkclass on<html>.- Tailwind v4
@custom-variant dark (&:is(.dark *))enables alldark:utilities based on that class. ThemeSwitchrenders three icon buttons (sun/moon/monitor) insideAnimatedBackground.AnimatedBackgrounduses Motion'slayoutIdto animate a shared background pill between whichever button is active — a spring animation with zero bounce and 0.2s duration.- The
mountedguard inThemeSwitchprevents hydration mismatches since theme is unknown during SSR.