Theme Switch

A theme switch component for Next.js apps with next-themes and Tailwind CSS, supporting system, light, and dark modes.

Installation

CLI

npx shadcn@latest add https://kelvinmai.io/r/theme-switch.json

Manual

Install the following dependencies

npm install next-themes lucide-react motion clsx tailwind-merge

Add a cn helper

lib/utils.ts
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export const cn = (...inputs: ClassValue[]) => {
return twMerge(clsx(inputs));
};

Set up next-themes in your Next.js app

app/layout.tsx
import { ThemeProvider } from 'next-themes';
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<ThemeProvider attribute='class' defaultTheme='system' enableSystem>
{children}
</ThemeProvider>
);
}

Copy and paste the following code into your project

components/theme-switch.tsx
'use client';
import React, { type JSX, useEffect, useState } from 'react';
import { MonitorIcon, MoonStarIcon, SunIcon } from 'lucide-react';
import { motion } from 'motion/react';
import { useTheme } from 'next-themes';
import { cn } from '@/lib/utils';
const ThemeOption = ({
icon,
value,
isActive,
onClick,
}: {
icon: JSX.Element;
value: string;
isActive?: boolean;
onClick: (value: string) => void;
}) => {
return (
<button
className={cn(
'relative flex size-8 cursor-default items-center justify-center rounded-full transition-all [&_svg]:size-4',
isActive
? 'text-zinc-950 dark:text-zinc-50'
: 'text-zinc-400 hover:text-zinc-950 dark:text-zinc-500 dark:hover:text-zinc-50',
)}
role='radio'
aria-checked={isActive}
aria-label={`Switch to ${value} theme`}
onClick={() => onClick(value)}
>
{icon}
{isActive && (
<motion.div
layoutId='theme-option'
transition={{ type: 'spring', bounce: 0.3, duration: 0.6 }}
className='absolute inset-0 rounded-full border border-zinc-200 dark:border-zinc-700'
/>
)}
</button>
);
};
const THEME_OPTIONS = [
{
icon: <MonitorIcon />,
value: 'system',
},
{
icon: <SunIcon />,
value: 'light',
},
{
icon: <MoonStarIcon />,
value: 'dark',
},
];
const ThemeSwitch = () => {
const { theme, setTheme } = useTheme();
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
if (!isMounted) {
return <div className='flex h-8 w-24' />;
}
return (
<motion.div
key={String(isMounted)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
className='inline-flex items-center overflow-hidden rounded-full bg-white ring-1 ring-zinc-200 ring-inset dark:bg-zinc-950 dark:ring-zinc-700'
role='radiogroup'
>
{THEME_OPTIONS.map((option) => (
<ThemeOption
key={option.value}
icon={option.icon}
value={option.value}
isActive={theme === option.value}
onClick={setTheme}
/>
))}
</motion.div>
);
};
export { ThemeSwitch };

Update the import paths to match your project setup

Usage

import { ThemeSwitcher } from '@/components/theme-switcher';
<ThemeSwitcher />;

API Reference