Theme picker dropdown with standalone, icon button, and sub-menu variants supporting light, warm, stone, dark, slate, and system themes
Install
npx shadcn add https://ui.happenings.social/r/theme-switcher.json
Or with namespace
npx shadcn add @happenings/theme-switcher
Preview
Dependencies
Registry dependencies
src/components/ui/theme-switcher.tsx
"use client"
import { useSyncExternalStore } from "react"
import { useTheme } from "next-themes"
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core"
import {
faCheck,
faCircleHalfStroke,
faDroplet,
faMoon,
faMountain,
faSun,
faSunBright,
} from "@fortawesome/pro-solid-svg-icons"
import { cn } from "@/lib/utils"
import { Button } from "./button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "./dropdown-menu"
interface ThemeOption {
value: string
icon: IconDefinition
label: string
}
const DEFAULT_THEMES: ThemeOption[] = [
{ value: "light", icon: faSun, label: "Light" },
{ value: "warm", icon: faSunBright, label: "Warm" },
{ value: "stone", icon: faMountain, label: "Stone" },
{ value: "dark", icon: faMoon, label: "Dark" },
{ value: "slate", icon: faDroplet, label: "Slate" },
{ value: "system", icon: faCircleHalfStroke, label: "Auto" },
]
const emptySubscribe = () => () => {}
const getTrue = () => true
const getFalse = () => false
function themeIcon(resolvedTheme: string | undefined): IconDefinition {
if (resolvedTheme === "warm") return faSunBright
if (resolvedTheme === "stone") return faMountain
if (resolvedTheme === "slate") return faDroplet
if (resolvedTheme === "dark") return faMoon
return faSun
}
function ThemeMenuItems({ themes = DEFAULT_THEMES }: { themes?: ThemeOption[] | undefined }) {
const { theme, setTheme } = useTheme()
return (
<>
{themes.map((opt) => (
<DropdownMenuItem
key={opt.value}
onClick={() => setTheme(opt.value)}
className="flex items-center justify-between"
>
<span className="flex items-center gap-2">
<FontAwesomeIcon icon={opt.icon} className="size-3 text-zinc-500" />
{opt.label}
</span>
{theme === opt.value && (
<FontAwesomeIcon icon={faCheck} className="size-3 text-zinc-500" />
)}
</DropdownMenuItem>
))}
</>
)
}
function ThemeSwitcher({
className,
themes,
}: {
className?: string | undefined
themes?: ThemeOption[] | undefined
}) {
const { resolvedTheme } = useTheme()
const mounted = useSyncExternalStore(emptySubscribe, getTrue, getFalse)
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"size-8 rounded-lg text-zinc-500 hover:text-zinc-950 dark:text-zinc-400 dark:hover:text-zinc-50",
className,
)}
aria-label="Theme"
>
{mounted && (
<FontAwesomeIcon
icon={themeIcon(resolvedTheme)}
className="size-3.5"
/>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[160px]">
<ThemeMenuItems themes={themes} />
</DropdownMenuContent>
</DropdownMenu>
)
}
function ThemeSubMenu({
label,
themes,
}: {
label: string
themes?: ThemeOption[] | undefined
}) {
const { resolvedTheme } = useTheme()
const mounted = useSyncExternalStore(emptySubscribe, getTrue, getFalse)
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<span className="flex items-center gap-2">
{mounted && (
<FontAwesomeIcon
icon={themeIcon(resolvedTheme)}
className="size-3.5"
/>
)}
{label}
</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<ThemeMenuItems themes={themes} />
</DropdownMenuSubContent>
</DropdownMenuSub>
)
}
export { ThemeSwitcher, ThemeSubMenu, ThemeMenuItems, DEFAULT_THEMES, themeIcon }
export type { ThemeOption }
Preview