theme-switch.tsx 2.7 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283
  1. 'use client';
  2. import { Moon, Sun } from 'lucide-react';
  3. import { useEffect, useMemo, useState } from 'react';
  4. import type { ComponentProps } from 'react';
  5. import { cn } from '@/lib/cn';
  6. type ThemeMode = 'light-dark' | 'light-dark-system';
  7. type ThemePref = 'light' | 'dark' | 'system';
  8. const STORAGE_KEY = 'docs-theme';
  9. function getStoredTheme(): ThemePref {
  10. if (typeof window === 'undefined') return 'system';
  11. const raw = window.localStorage.getItem(STORAGE_KEY);
  12. return raw === 'light' || raw === 'dark' || raw === 'system' ? raw : 'system';
  13. }
  14. function getResolvedTheme(theme: ThemePref): 'light' | 'dark' {
  15. if (theme !== 'system') return theme;
  16. if (typeof window === 'undefined') return 'light';
  17. return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
  18. }
  19. function applyTheme(theme: ThemePref): void {
  20. if (typeof document === 'undefined') return;
  21. const resolved = getResolvedTheme(theme);
  22. const root = document.documentElement;
  23. root.classList.toggle('dark', resolved === 'dark');
  24. root.style.colorScheme = resolved;
  25. }
  26. export function DocsThemeSwitch({
  27. className,
  28. mode = 'light-dark-system',
  29. ...props
  30. }: {
  31. className?: string;
  32. mode?: ThemeMode;
  33. } & Omit<ComponentProps<'div'>, 'children'>) {
  34. const [theme, setTheme] = useState<ThemePref>(getStoredTheme);
  35. useEffect(() => {
  36. window.localStorage.setItem(STORAGE_KEY, theme);
  37. applyTheme(theme);
  38. }, [theme]);
  39. useEffect(() => {
  40. if (theme !== 'system') return;
  41. const media = window.matchMedia('(prefers-color-scheme: dark)');
  42. const update = () => applyTheme('system');
  43. media.addEventListener('change', update);
  44. return () => media.removeEventListener('change', update);
  45. }, [theme]);
  46. const resolved = useMemo(() => getResolvedTheme(theme), [theme]);
  47. const nextTheme = () => {
  48. if (mode === 'light-dark') return resolved === 'dark' ? 'light' : 'dark';
  49. if (theme === 'light') return 'dark';
  50. if (theme === 'dark') return 'system';
  51. return resolved === 'dark' ? 'light' : 'dark';
  52. };
  53. const label =
  54. mode === 'light-dark-system'
  55. ? `Switch theme (current: ${theme})`
  56. : `Switch to ${resolved === 'dark' ? 'light' : 'dark'} mode`;
  57. return (
  58. <div className={cn('inline-flex', className)} {...props}>
  59. <button
  60. type="button"
  61. aria-label={label}
  62. title={label}
  63. onClick={() => setTheme(nextTheme())}
  64. className="inline-flex size-8 items-center justify-center rounded-lg text-fd-muted-foreground transition-colors hover:bg-fd-accent hover:text-fd-accent-foreground"
  65. >
  66. {resolved === 'dark' ? <Moon className="size-4" /> : <Sun className="size-4" />}
  67. </button>
  68. </div>
  69. );
  70. }