Skip to main content
The ThemeSwitcher is a pre-built dropdown component that provides an intuitive UI for users to switch between color themes and toggle between light and dark modes.

Installation

The ThemeSwitcher is automatically included when you install the theme system:
npx shadcn@latest add https://tweakcn-picker.vercel.app/r/nextjs/theme-system.json

Usage

Add the ThemeSwitcher to your navigation or header:
components/layout/header.tsx
import { ThemeSwitcher } from "@/components/theme-switcher";

export function Header() {
  return (
    <header className="border-b">
      <div className="container flex items-center justify-between h-16">
        <nav className="flex items-center gap-6">
          <a href="/">Home</a>
          <a href="/about">About</a>
        </nav>
        <ThemeSwitcher />
      </div>
    </header>
  );
}

Features

The ThemeSwitcher provides:
  • Mode Toggle: Switch between light and dark modes with visual indicators
  • Color Theme Selection: Browse and select from all installed themes
  • Visual Previews: See color swatches for each theme
  • Current Selection: Check marks show the active theme and mode
  • Scrollable List: Handles large theme collections with a scrollable menu
  • Theme Attribution: Credits tweakcn in the footer

Implementation

components/theme-switcher.tsx
"use client";

import { Check, Moon, Palette, Sun } from "lucide-react";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuPortal,
  DropdownMenuSeparator,
  DropdownMenuSub,
  DropdownMenuSubContent,
  DropdownMenuSubTrigger,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { sortedThemes, themes } from "@/lib/themes-config";
import { useEffect, useState } from "react";

import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { useTheme } from "next-themes";

// Helper to parse theme string like "catppuccin-dark"
function parseTheme(theme: string | undefined): {
  colorTheme: string;
  mode: "light" | "dark";
} {
  if (!theme) return { colorTheme: "default", mode: "dark" };

  if (theme.endsWith("-dark")) {
    return { colorTheme: theme.replace("-dark", ""), mode: "dark" };
  }
  if (theme.endsWith("-light")) {
    return { colorTheme: theme.replace("-light", ""), mode: "light" };
  }
  return { colorTheme: "default", mode: "dark" };
}

export function ThemeSwitcher() {
  const [mounted, setMounted] = useState(false);
  const { theme, setTheme } = useTheme();

  useEffect(() => {
    setMounted(true);
  }, []);

  const { colorTheme, mode } = parseTheme(theme);

  const setColorTheme = (name: string) => {
    setTheme(`${name}-${mode}`);
  };

  const setMode = (newMode: "light" | "dark") => {
    setTheme(`${colorTheme}-${newMode}`);
  };

  if (!mounted) {
    return (
      <Button variant="ghost" size="icon" className="h-9 w-9">
        <Sun className="h-4 w-4" />
      </Button>
    );
  }

  const currentThemeConfig =
    themes.find((t) => t.name === colorTheme) || themes[0];
  const isDark = mode === "dark";

  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="ghost" size="icon" className="h-9 w-9">
          {isDark ? <Moon className="h-4 w-4" /> : <Sun className="h-4 w-4" />}
          <span className="sr-only">Toggle theme</span>
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end" className="w-48">
        <DropdownMenuLabel className="flex items-center gap-2">
          <div
            className="h-3 w-3 rounded-full"
            style={{
              backgroundColor: isDark
                ? currentThemeConfig.primaryDark
                : currentThemeConfig.primaryLight,
            }}
          />
          {currentThemeConfig.title}
        </DropdownMenuLabel>
        <DropdownMenuSeparator />

        {/* Mode Toggle */}
        <DropdownMenuItem
          onClick={() => setMode("light")}
          className={cn("flex items-center gap-2 cursor-pointer")}
        >
          <Sun className="h-4 w-4" />
          <span className="flex-1">Light</span>
          {mode === "light" && <Check className="h-4 w-4 text-primary" />}
        </DropdownMenuItem>
        <DropdownMenuItem
          onClick={() => setMode("dark")}
          className={cn("flex items-center gap-2 cursor-pointer")}
        >
          <Moon className="h-4 w-4" />
          <span className="flex-1">Dark</span>
          {mode === "dark" && <Check className="h-4 w-4 text-primary" />}
        </DropdownMenuItem>

        <DropdownMenuSeparator />

        {/* Color Theme Submenu */}
        <DropdownMenuSub>
          <DropdownMenuSubTrigger className="flex items-center gap-2">
            <Palette className="h-4 w-4" />
            <span>Color Theme</span>
          </DropdownMenuSubTrigger>
          <DropdownMenuPortal>
            <DropdownMenuSubContent className="w-52">
              <ScrollArea className="h-80">
                <div className="p-1">
                  {sortedThemes.map((t) => (
                    <DropdownMenuItem
                      key={t.name}
                      onClick={() => setColorTheme(t.name)}
                      className="flex items-center gap-3 cursor-pointer"
                    >
                      <div
                        className="h-4 w-4 rounded-full border border-border shrink-0"
                        style={{
                          backgroundColor: isDark
                            ? t.primaryDark
                            : t.primaryLight,
                        }}
                      />
                      <span className="flex-1">{t.title}</span>
                      {colorTheme === t.name && (
                        <Check className="h-4 w-4 text-primary" />
                      )}
                    </DropdownMenuItem>
                  ))}
                </div>
              </ScrollArea>
              <DropdownMenuSeparator />
              <div className="px-2 py-1.5 text-xs text-muted-foreground text-center">
                Themes by{" "}
                <a
                  href="https://tweakcn.com"
                  target="_blank"
                  rel="noopener noreferrer"
                  className="font-medium text-foreground hover:underline"
                  onClick={(e) => e.stopPropagation()}
                >
                  tweakcn
                </a>
              </div>
            </DropdownMenuSubContent>
          </DropdownMenuPortal>
        </DropdownMenuSub>
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

How It Works

Theme Parsing

The component uses a parseTheme helper function to extract the color theme name and mode from the combined theme string:
// Input: "catppuccin-dark"
// Output: { colorTheme: "catppuccin", mode: "dark" }

function parseTheme(theme: string | undefined): {
  colorTheme: string;
  mode: "light" | "dark";
} {
  if (!theme) return { colorTheme: "default", mode: "dark" };

  if (theme.endsWith("-dark")) {
    return { colorTheme: theme.replace("-dark", ""), mode: "dark" };
  }
  if (theme.endsWith("-light")) {
    return { colorTheme: theme.replace("-light", ""), mode: "light" };
  }
  return { colorTheme: "default", mode: "dark" };
}

Mode Switching

When toggling between light and dark modes, the component preserves the current color theme:
const setMode = (newMode: "light" | "dark") => {
  // If currently on "catppuccin-dark" and switching to light:
  // Result: "catppuccin-light"
  setTheme(`${colorTheme}-${newMode}`);
};

Color Theme Switching

When changing color themes, the component preserves the current mode:
const setColorTheme = (name: string) => {
  // If currently on "catppuccin-dark" and switching to "vercel":
  // Result: "vercel-dark"
  setTheme(`${name}-${mode}`);
};

Hydration Safety

The component uses a mounted state to prevent hydration mismatches:
const [mounted, setMounted] = useState(false);

useEffect(() => {
  setMounted(true);
}, []);

if (!mounted) {
  return (
    <Button variant="ghost" size="icon" className="h-9 w-9">
      <Sun className="h-4 w-4" />
    </Button>
  );
}

Customization

Remove Attribution

If you want to remove the tweakcn attribution, delete lines 144-156 from the component.

Change Button Style

Customize the trigger button appearance:
<DropdownMenuTrigger asChild>
  <Button variant="outline" size="sm" className="gap-2">
    {isDark ? <Moon className="h-4 w-4" /> : <Sun className="h-4 w-4" />}
    <span>Theme</span>
  </Button>
</DropdownMenuTrigger>

Adjust Menu Width

Change the dropdown menu width:
<DropdownMenuContent align="end" className="w-64"> {/* Changed from w-48 */}

Modify Scroll Height

Adjust the scrollable area height:
<ScrollArea className="h-96"> {/* Changed from h-80 */}

Filter Themes

Show only specific theme categories:
import { themes } from "@/lib/themes-config";

// Only show minimal themes
const minimalThemes = themes.filter((t) => 
  ["default", "mono", "mocha-mousse", "modern-minimal"].includes(t.name)
);

// Use minimalThemes instead of sortedThemes in the map
{minimalThemes.map((t) => (
  // ...
))}

Dependencies

The ThemeSwitcher requires these shadcn/ui components:
  • button
  • dropdown-menu
  • scroll-area
These are automatically installed with the theme system. Icons are provided by lucide-react.

useTheme Hook

Control themes programmatically

Custom Selector

Build your own theme selector

Build docs developers (and LLMs) love