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.json
bash

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