Skip to main content
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>
  );
}

Using Theme Metadata

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:
themes
ThemeConfig[]
Array of all theme configurations in the order they are defined.
sortedThemes
ThemeConfig[]
Themes sorted alphabetically by title, with “Default” always first.
themeNames
string[]
Array of theme names only: ["default", "catppuccin", "cyberpunk", ...]
allThemeValues
string[]
All theme values including mode variants: ["default-light", "default-dark", "catppuccin-light", "catppuccin-dark", ...]
DEFAULT_THEME
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
});

useTheme Hook

Learn about the useTheme hook API

ThemeSwitcher

See the built-in theme switcher component

Build docs developers (and LLMs) love