Theme Toggle Component
A theme toggle component for switching between light, dark, and system themes.
Installation
pnpm dlx shadcn@latest add https://ui.vllnt.com/r/theme-toggle.jsonbash
Usage
import { ThemeToggle } from '@vllnt/ui'
export function ThemeToggleExample() {
return <ThemeToggle />
}tsx
Code
"use client";
import { useCallback, useRef, useState } from "react";
import { useTheme } from "next-themes";
import { useMounted } from "../../lib/use-mounted";
import { Button } from "../button/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "../dropdown-menu/dropdown-menu";
type ThemeToggleProps = {
dict: {
theme: {
dark: string;
light: string;
system: string;
toggle_theme: string;
};
};
};
type ThemeButtonProps = {
ariaLabel: string;
children?: React.ReactNode;
icon: string;
} & React.ButtonHTMLAttributes<HTMLButtonElement>;
function ThemeButton({
ariaLabel,
children,
icon,
...props
}: ThemeButtonProps) {
return (
<Button
aria-label={ariaLabel}
className="bg-background text-foreground border border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500 transition-colors"
size="icon"
variant="outline"
{...props}
>
<span className="text-sm font-mono">{icon}</span>
<span className="sr-only">{ariaLabel}</span>
{children}
</Button>
);
}
function ThemeMenuItem({
icon,
label,
onClick,
}: {
icon: string;
label: string;
onClick: () => void;
}) {
return (
<DropdownMenuItem
className="hover:bg-accent cursor-pointer"
onClick={onClick}
>
{icon} {label}
</DropdownMenuItem>
);
}
export function ThemeToggle({ dict }: ThemeToggleProps) {
const { setTheme, theme } = useTheme();
const mounted = useMounted();
const [open, setOpen] = useState(false);
const closeTimerReference = useRef<null | number>(null);
const isHoveringOverMenuAreaReference = useRef(false);
const clearCloseTimer = useCallback(() => {
if (closeTimerReference.current !== null) {
window.clearTimeout(closeTimerReference.current);
closeTimerReference.current = null;
}
}, []);
const scheduleClose = useCallback(() => {
clearCloseTimer();
closeTimerReference.current = window.setTimeout(() => {
setOpen(false);
}, 250);
}, [clearCloseTimer]);
const handleOpenChange = useCallback((nextOpen: boolean) => {
if (!nextOpen && isHoveringOverMenuAreaReference.current) {
return;
}
setOpen(nextOpen);
}, []);
const getThemeIcon = useCallback(() => {
if (!mounted) return "☀";
switch (theme) {
case "light":
return "☀";
case "dark":
return "☾";
case "system":
return "⚙";
case undefined:
return "⚙";
default:
return "⚙";
}
}, [theme, mounted]);
const themeIcon = getThemeIcon();
const handleThemeChange = useCallback(
(newTheme: string) => {
setTheme(newTheme);
},
[setTheme],
);
// Prevent hydration mismatch by not rendering until mounted
if (!mounted) {
return <ThemeButton ariaLabel={dict.theme.toggle_theme} icon="☀" />;
}
return (
<DropdownMenu modal={false} onOpenChange={handleOpenChange} open={open}>
<DropdownMenuTrigger asChild>
<ThemeButton
ariaLabel={dict.theme.toggle_theme}
icon={themeIcon}
onMouseEnter={() => {
isHoveringOverMenuAreaReference.current = true;
clearCloseTimer();
setOpen(true);
}}
onMouseLeave={() => {
isHoveringOverMenuAreaReference.current = false;
scheduleClose();
}}
/>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="bg-background border border-gray-300 dark:border-gray-600"
onMouseEnter={() => {
isHoveringOverMenuAreaReference.current = true;
clearCloseTimer();
}}
onMouseLeave={() => {
isHoveringOverMenuAreaReference.current = false;
scheduleClose();
}}
>
<ThemeMenuItem
icon="☀"
label={dict.theme.light}
onClick={() => {
handleThemeChange("light");
}}
/>
<ThemeMenuItem
icon="☾"
label={dict.theme.dark}
onClick={() => {
handleThemeChange("dark");
}}
/>
<ThemeMenuItem
icon="⚙"
label={dict.theme.system}
onClick={() => {
handleThemeChange("system");
}}
/>
</DropdownMenuContent>
</DropdownMenu>
);
}
typescript