Skip to content

Instantly share code, notes, and snippets.

@jpedroschmitz
Created March 30, 2026 19:33
Show Gist options
  • Select an option

  • Save jpedroschmitz/dd4c8b06483c2e82da2ab5010c476ab1 to your computer and use it in GitHub Desktop.

Select an option

Save jpedroschmitz/dd4c8b06483c2e82da2ab5010c476ab1 to your computer and use it in GitHub Desktop.
Dark Mode with next-themes + Tailwind CSS v4 + Motion for React (Next.js App Router)

Dark Mode with next-themes + Tailwind CSS v4 + Motion for React

A complete dark/light/system theme switcher for Next.js (App Router) with an animated sliding indicator.

Dependencies

pnpm add next-themes motion lucide-react clsx

1. Root Layout — app/layout.tsx

import { 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 .dark on <html>
  • defaultTheme="system" — respects OS preference out of the box
  • storageKey="theme" — persists choice to localStorage

2. Tailwind CSS v4 dark mode — app/globals.css

@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.

3. Theme Switcher — components/theme-switch.tsx

'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>
  )
}

4. Animated Background — components/ui/animated-background.tsx

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>
      </>,
    )
  })
}

5. Motion Provider — components/motion-provider.tsx

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 domMax

6. Utils — lib/utils.ts

import { ClassValue, clsx } from 'clsx'

export function cn(...inputs: ClassValue[]) {
  return clsx(inputs)
}

7. Usage example

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>
  )
}

How it works

  1. next-themes handles system preference detection (prefers-color-scheme), localStorage persistence, and toggling the .dark class on <html>.
  2. Tailwind v4 @custom-variant dark (&:is(.dark *)) enables all dark: utilities based on that class.
  3. ThemeSwitch renders three icon buttons (sun/moon/monitor) inside AnimatedBackground.
  4. AnimatedBackground uses Motion's layoutId to animate a shared background pill between whichever button is active — a spring animation with zero bounce and 0.2s duration.
  5. The mounted guard in ThemeSwitch prevents hydration mismatches since theme is unknown during SSR.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment