1
0

theme-switch.tsx 3.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
  1. 'use client';
  2. import { Moon, Sun } from 'lucide-react';
  3. import { useEffect, useState, useSyncExternalStore } 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. // `useSyncExternalStore` supplies the same value for SSR and hydration, then
  10. // switches to the browser value after React has attached to the markup.
  11. const subscribeToHydration = () => () => {};
  12. const getHydrationClientSnapshot = () => true;
  13. const getHydrationServerSnapshot = () => false;
  14. function getStoredTheme(): ThemePref {
  15. if (typeof window === 'undefined') return 'system';
  16. const raw = window.localStorage.getItem(STORAGE_KEY);
  17. return raw === 'light' || raw === 'dark' || raw === 'system' ? raw : 'system';
  18. }
  19. function getResolvedTheme(theme: ThemePref): 'light' | 'dark' {
  20. if (theme !== 'system') return theme;
  21. if (typeof window === 'undefined') return 'light';
  22. return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
  23. }
  24. function applyTheme(theme: ThemePref): void {
  25. if (typeof document === 'undefined') return;
  26. const resolved = getResolvedTheme(theme);
  27. const root = document.documentElement;
  28. root.classList.toggle('dark', resolved === 'dark');
  29. root.style.colorScheme = resolved;
  30. }
  31. export function DocsThemeSwitch({
  32. className,
  33. mode = 'light-dark-system',
  34. ...props
  35. }: {
  36. className?: string;
  37. mode?: ThemeMode;
  38. } & Omit<ComponentProps<'div'>, 'children'>) {
  39. // Keep the server and first client render identical. Reading localStorage or
  40. // matchMedia here would make a persisted/system preference change the client
  41. // markup before React has finished hydrating it.
  42. const [selectedTheme, setSelectedTheme] = useState<ThemePref>('system');
  43. const hydrated = useSyncExternalStore(
  44. subscribeToHydration,
  45. getHydrationClientSnapshot,
  46. getHydrationServerSnapshot,
  47. );
  48. const theme = hydrated ? getStoredTheme() : selectedTheme;
  49. useEffect(() => {
  50. if (hydrated) applyTheme(theme);
  51. }, [hydrated, theme]);
  52. useEffect(() => {
  53. if (!hydrated) return;
  54. if (theme !== 'system') return;
  55. const media = window.matchMedia('(prefers-color-scheme: dark)');
  56. const update = () => applyTheme('system');
  57. media.addEventListener('change', update);
  58. return () => media.removeEventListener('change', update);
  59. }, [hydrated, theme]);
  60. const resolved = hydrated ? getResolvedTheme(theme) : 'light';
  61. const setTheme = (nextTheme: ThemePref) => {
  62. window.localStorage.setItem(STORAGE_KEY, nextTheme);
  63. applyTheme(nextTheme);
  64. setSelectedTheme(nextTheme);
  65. };
  66. const nextTheme = () => {
  67. if (mode === 'light-dark') return resolved === 'dark' ? 'light' : 'dark';
  68. if (theme === 'light') return 'dark';
  69. if (theme === 'dark') return 'system';
  70. return resolved === 'dark' ? 'light' : 'dark';
  71. };
  72. const label =
  73. mode === 'light-dark-system'
  74. ? `Switch theme (current: ${theme})`
  75. : `Switch to ${resolved === 'dark' ? 'light' : 'dark'} mode`;
  76. return (
  77. <div className={cn('inline-flex', className)} {...props}>
  78. <button
  79. type="button"
  80. aria-label={label}
  81. title={label}
  82. onClick={() => setTheme(nextTheme())}
  83. 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"
  84. >
  85. {resolved === 'dark' ? <Moon className="size-4" /> : <Sun className="size-4" />}
  86. </button>
  87. </div>
  88. );
  89. }