While the built-in 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 ThemeProvider to 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 >
);
}
"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 >
);
}
The themes-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:
Array of all theme configurations in the order they are defined.
Themes sorted alphabetically by title, with “Default” always first.
Array of theme names only: ["default", "catppuccin", "cyberpunk", ...]
All theme values including mode variants: ["default-light", "default-dark", "catppuccin-light", "catppuccin-dark", ...]
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
});
useTheme Hook Learn about the useTheme hook API
ThemeSwitcher See the built-in theme switcher component