ThemeSwitcher component provides a complete solution, you may want to create a custom theme selector to match your specific design requirements. This guide shows you how to build custom selectors using the useTheme hook and theme configuration.
Prerequisites
Before building a custom selector, ensure you have:- Installed the theme system
- Added the
ThemeProviderto your app root - Installed at least one theme
Basic Custom Selector
Here’s a minimal example from the README showing how to build a custom selector:import { useTheme } from "next-themes";
import { themes } from "@/lib/themes-config";
export function CustomThemeSelector() {
const { theme, setTheme } = useTheme();
// Parse current theme
const currentName = theme?.replace(/-light$|-dark$/, "") || "default";
const isDark = theme?.endsWith("-dark") ?? true;
const toggleMode = () => {
setTheme(`${currentName}-${isDark ? "light" : "dark"}`);
};
const selectTheme = (name: string) => {
setTheme(`${name}-${isDark ? "dark" : "light"}`);
};
return (
<div>
<button onClick={toggleMode}>
{isDark ? "Switch to Light" : "Switch to Dark"}
</button>
<select value={currentName} onChange={(e) => selectTheme(e.target.value)}>
{themes.map((t) => (
<option key={t.name} value={t.name}>
{t.title}
</option>
))}
</select>
</div>
);
}
Key Concepts
Theme Parsing
Tweakcn themes combine the theme name and mode into a single string (e.g.,"catppuccin-dark"). Parse them to work with each component separately:
const currentName = theme?.replace(/-light$|-dark$/, "") || "default";
const isDark = theme?.endsWith("-dark") ?? true;
Independent Controls
Provide separate controls for color theme and mode:// Change mode, preserve color theme
const toggleMode = () => {
setTheme(`${currentName}-${isDark ? "light" : "dark"}`);
};
// Change color theme, preserve mode
const selectTheme = (name: string) => {
setTheme(`${name}-${isDark ? "dark" : "light"}`);
};
Advanced Examples
Grid-Based Selector with Color Swatches
"use client";
import { useTheme } from "next-themes";
import { themes } from "@/lib/themes-config";
import { Moon, Sun } from "lucide-react";
export function GridThemeSelector() {
const { theme, setTheme } = useTheme();
const currentName = theme?.replace(/-light$|-dark$/, "") || "default";
const isDark = theme?.endsWith("-dark") ?? true;
const toggleMode = () => {
setTheme(`${currentName}-${isDark ? "light" : "dark"}`);
};
const selectTheme = (name: string) => {
setTheme(`${name}-${isDark ? "dark" : "light"}`);
};
return (
<div className="space-y-4">
{/* Mode Toggle */}
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Mode</span>
<button
onClick={toggleMode}
className="flex items-center gap-2 px-3 py-2 rounded-lg border hover:bg-accent"
>
{isDark ? <Moon className="h-4 w-4" /> : <Sun className="h-4 w-4" />}
{isDark ? "Dark" : "Light"}
</button>
</div>
{/* Theme Grid */}
<div className="space-y-2">
<span className="text-sm font-medium">Color Theme</span>
<div className="grid grid-cols-2 gap-2">
{themes.map((t) => {
const isActive = currentName === t.name;
return (
<button
key={t.name}
onClick={() => selectTheme(t.name)}
className={`flex items-center gap-2 p-2 rounded-lg border transition-colors ${
isActive
? "border-primary bg-primary/5"
: "hover:bg-accent"
}`}
>
<div
className="h-6 w-6 rounded-full border shrink-0"
style={{
backgroundColor: isDark ? t.primaryDark : t.primaryLight,
}}
/>
<span className="text-sm">{t.title}</span>
</button>
);
})}
</div>
</div>
</div>
);
}
Tabs-Based Selector
"use client";
import { useTheme } from "next-themes";
import { themes, sortedThemes } from "@/lib/themes-config";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
export function TabsThemeSelector() {
const { theme, setTheme } = useTheme();
const currentName = theme?.replace(/-light$|-dark$/, "") || "default";
const isDark = theme?.endsWith("-dark") ?? true;
const setMode = (mode: "light" | "dark") => {
setTheme(`${currentName}-${mode}`);
};
const selectTheme = (name: string) => {
setTheme(`${name}-${isDark ? "dark" : "light"}`);
};
return (
<Tabs value={isDark ? "dark" : "light"} onValueChange={(v) => setMode(v as "light" | "dark")}>
<TabsList className="w-full">
<TabsTrigger value="light" className="flex-1">Light</TabsTrigger>
<TabsTrigger value="dark" className="flex-1">Dark</TabsTrigger>
</TabsList>
<TabsContent value="light" className="space-y-2 mt-4">
{sortedThemes.map((t) => (
<button
key={t.name}
onClick={() => selectTheme(t.name)}
className={`w-full flex items-center gap-3 p-3 rounded-lg border transition-colors ${
currentName === t.name && !isDark
? "border-primary bg-primary/5"
: "hover:bg-accent"
}`}
>
<div
className="h-8 w-8 rounded-lg border shrink-0"
style={{ backgroundColor: t.primaryLight }}
/>
<div className="flex-1 text-left">
<p className="font-medium">{t.title}</p>
<p className="text-xs text-muted-foreground">{t.fontSans}</p>
</div>
</button>
))}
</TabsContent>
<TabsContent value="dark" className="space-y-2 mt-4">
{sortedThemes.map((t) => (
<button
key={t.name}
onClick={() => selectTheme(t.name)}
className={`w-full flex items-center gap-3 p-3 rounded-lg border transition-colors ${
currentName === t.name && isDark
? "border-primary bg-primary/5"
: "hover:bg-accent"
}`}
>
<div
className="h-8 w-8 rounded-lg border shrink-0"
style={{ backgroundColor: t.primaryDark }}
/>
<div className="flex-1 text-left">
<p className="font-medium">{t.title}</p>
<p className="text-xs text-muted-foreground">{t.fontSans}</p>
</div>
</button>
))}
</TabsContent>
</Tabs>
);
}
Color Palette Selector
Show only the color swatches for a minimal design:"use client";
import { useTheme } from "next-themes";
import { themes } from "@/lib/themes-config";
import { Check } from "lucide-react";
export function PaletteSelector() {
const { theme, setTheme } = useTheme();
const currentName = theme?.replace(/-light$|-dark$/, "") || "default";
const isDark = theme?.endsWith("-dark") ?? true;
const selectTheme = (name: string) => {
setTheme(`${name}-${isDark ? "dark" : "light"}`);
};
return (
<div className="flex flex-wrap gap-2">
{themes.map((t) => {
const isActive = currentName === t.name;
return (
<button
key={t.name}
onClick={() => selectTheme(t.name)}
className="relative group"
title={t.title}
>
<div
className={`h-10 w-10 rounded-full border-2 transition-all ${
isActive ? "border-foreground scale-110" : "border-transparent hover:scale-105"
}`}
style={{
backgroundColor: isDark ? t.primaryDark : t.primaryLight,
}}
/>
{isActive && (
<div className="absolute inset-0 flex items-center justify-center">
<Check className="h-5 w-5 text-white drop-shadow-lg" />
</div>
)}
</button>
);
})}
</div>
);
}
Sidebar Theme Settings Panel
"use client";
import { useTheme } from "next-themes";
import { sortedThemes } from "@/lib/themes-config";
import { Moon, Sun, Palette } from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
export function ThemeSettingsPanel() {
const { theme, setTheme } = useTheme();
const currentName = theme?.replace(/-light$|-dark$/, "") || "default";
const isDark = theme?.endsWith("-dark") ?? true;
const setMode = (mode: "light" | "dark") => {
setTheme(`${currentName}-${mode}`);
};
const selectTheme = (name: string) => {
setTheme(`${name}-${isDark ? "dark" : "light"}`);
};
return (
<div className="w-80 border-l h-screen">
<div className="p-4 space-y-4">
{/* Header */}
<div className="flex items-center gap-2">
<Palette className="h-5 w-5" />
<h2 className="text-lg font-semibold">Theme Settings</h2>
</div>
<Separator />
{/* Appearance Mode */}
<div className="space-y-2">
<label className="text-sm font-medium">Appearance</label>
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => setMode("light")}
className={`flex items-center justify-center gap-2 p-3 rounded-lg border transition-colors ${
!isDark
? "border-primary bg-primary/5"
: "hover:bg-accent"
}`}
>
<Sun className="h-4 w-4" />
<span className="text-sm">Light</span>
</button>
<button
onClick={() => setMode("dark")}
className={`flex items-center justify-center gap-2 p-3 rounded-lg border transition-colors ${
isDark
? "border-primary bg-primary/5"
: "hover:bg-accent"
}`}
>
<Moon className="h-4 w-4" />
<span className="text-sm">Dark</span>
</button>
</div>
</div>
<Separator />
{/* Theme List */}
<div className="space-y-2">
<label className="text-sm font-medium">Color Theme</label>
<ScrollArea className="h-[calc(100vh-280px)]">
<div className="space-y-1 pr-4">
{sortedThemes.map((t) => {
const isActive = currentName === t.name;
return (
<button
key={t.name}
onClick={() => selectTheme(t.name)}
className={`w-full flex items-center gap-3 p-3 rounded-lg border transition-colors text-left ${
isActive
? "border-primary bg-primary/5"
: "hover:bg-accent"
}`}
>
<div
className="h-5 w-5 rounded-full border shrink-0"
style={{
backgroundColor: isDark ? t.primaryDark : t.primaryLight,
}}
/>
<span className="text-sm">{t.title}</span>
</button>
);
})}
</div>
</ScrollArea>
</div>
</div>
</div>
);
}
Using Theme Metadata
Thethemes-config.ts file provides rich metadata for each theme:
import { themes } from "@/lib/themes-config";
// Access theme properties
themes.forEach((theme) => {
console.log(theme.name); // "catppuccin"
console.log(theme.title); // "Catppuccin"
console.log(theme.primaryLight); // "oklch(0.55 0.25 297.02)"
console.log(theme.primaryDark); // "oklch(0.79 0.12 304.77)"
console.log(theme.fontSans); // "Montserrat, sans-serif"
});
Available Exports
Import these from@/lib/themes-config:
ThemeConfig[]
Array of all theme configurations in the order they are defined.
ThemeConfig[]
Themes sorted alphabetically by title, with “Default” always first.
string[]
Array of theme names only:
["default", "catppuccin", "cyberpunk", ...]string[]
All theme values including mode variants:
["default-light", "default-dark", "catppuccin-light", "catppuccin-dark", ...]string
The default theme value:
"default-dark"ThemeConfig Interface
interface ThemeConfig {
name: string; // Theme identifier (e.g., "catppuccin")
title: string; // Display name (e.g., "Catppuccin")
primaryLight: string; // Primary color for light mode (OKLCH)
primaryDark: string; // Primary color for dark mode (OKLCH)
fontSans: string; // Font family
}
Best Practices
Always Include Mode Suffix
Never set a theme without the mode suffix:// ✓ Correct
setTheme(`${themeName}-dark`);
setTheme(`${themeName}-light`);
// ✗ Incorrect - will not work
setTheme(themeName);
Handle Hydration
Prevent hydration mismatches by checking if the component is mounted:import { useEffect, useState } from "react";
import { useTheme } from "next-themes";
export function CustomSelector() {
const [mounted, setMounted] = useState(false);
const { theme } = useTheme();
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null;
// Your selector UI
}
Preserve User Choice
When switching modes, preserve the color theme. When switching themes, preserve the mode:// Switch mode: "catppuccin-dark" -> "catppuccin-light"
const toggleMode = () => {
setTheme(`${currentName}-${isDark ? "light" : "dark"}`);
};
// Switch theme: "catppuccin-dark" -> "vercel-dark"
const selectTheme = (name: string) => {
setTheme(`${name}-${isDark ? "dark" : "light"}`);
};
Use Semantic Colors
When displaying theme colors, use the appropriate mode variant:const colorToDisplay = isDark ? theme.primaryDark : theme.primaryLight;
Filtering Themes
Show only specific categories or themes:import { themes } from "@/lib/themes-config";
// By category (based on naming)
const minimalThemes = themes.filter((t) =>
["default", "mono", "mocha-mousse", "modern-minimal", "clean-slate"].includes(t.name)
);
const brandedThemes = themes.filter((t) =>
["claude", "vercel", "twitter", "github", "supabase"].includes(t.name)
);
// By color intensity (example)
const vibrantThemes = themes.filter((t) => {
// Parse OKLCH chroma value (second parameter)
const chromaMatch = t.primaryLight.match(/oklch\([\d.]+ ([\d.]+) [\d.]+\)/);
const chroma = chromaMatch ? parseFloat(chromaMatch[1]) : 0;
return chroma > 0.15; // High chroma = vibrant colors
});
Related
useTheme Hook
Learn about the useTheme hook API
ThemeSwitcher
See the built-in theme switcher component