ThemeToggle.tsx 2.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
  1. import React, { useEffect, useState } from "react"
  2. type Theme = "light" | "dark" | "system"
  3. export function ThemeToggle() {
  4. const [theme, setTheme] = useState<Theme>("system")
  5. const [mounted, setMounted] = useState(false)
  6. // On mount, read the preference from localStorage or default to 'system'
  7. useEffect(() => {
  8. setMounted(true)
  9. const storedTheme = localStorage.getItem("theme") as Theme | null
  10. if (storedTheme) {
  11. setTheme(storedTheme)
  12. }
  13. }, [])
  14. // Apply the theme to the document
  15. useEffect(() => {
  16. if (!mounted) return
  17. const root = document.documentElement
  18. if (theme === "system") {
  19. localStorage.removeItem("theme")
  20. const systemDark = window.matchMedia("(prefers-color-scheme: dark)").matches
  21. root.classList.toggle("dark", systemDark)
  22. } else {
  23. localStorage.setItem("theme", theme)
  24. root.classList.toggle("dark", theme === "dark")
  25. }
  26. }, [theme, mounted])
  27. // Listen for system preference changes
  28. useEffect(() => {
  29. if (!mounted) return
  30. const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
  31. const handleChange = (e: MediaQueryListEvent) => {
  32. if (theme === "system") {
  33. document.documentElement.classList.toggle("dark", e.matches)
  34. }
  35. }
  36. mediaQuery.addEventListener("change", handleChange)
  37. return () => mediaQuery.removeEventListener("change", handleChange)
  38. }, [theme, mounted])
  39. const cycleTheme = () => {
  40. const themes: Theme[] = ["system", "light", "dark"]
  41. const currentIndex = themes.indexOf(theme)
  42. const nextIndex = (currentIndex + 1) % themes.length
  43. setTheme(themes[nextIndex])
  44. }
  45. // Avoid hydration mismatch by not rendering until mounted
  46. if (!mounted) {
  47. return (
  48. <button className="theme-toggle" aria-label="Toggle theme" style={{ width: "32px", height: "32px" }}>
  49. <span style={{ opacity: 0 }}>🌙</span>
  50. </button>
  51. )
  52. }
  53. const getIcon = () => {
  54. if (theme === "system") {
  55. return "💻"
  56. }
  57. if (theme === "dark") {
  58. return "🌙"
  59. }
  60. return "☀️"
  61. }
  62. const getLabel = () => {
  63. if (theme === "system") {
  64. return "Using system theme"
  65. }
  66. if (theme === "dark") {
  67. return "Dark mode"
  68. }
  69. return "Light mode"
  70. }
  71. return (
  72. <>
  73. <button onClick={cycleTheme} className="theme-toggle" aria-label={getLabel()} title={getLabel()}>
  74. <span>{getIcon()}</span>
  75. </button>
  76. <style jsx>{`
  77. .theme-toggle {
  78. display: flex;
  79. align-items: center;
  80. justify-content: center;
  81. width: 32px;
  82. height: 32px;
  83. padding: 0;
  84. border: 1px solid var(--border-color);
  85. border-radius: 6px;
  86. background: var(--bg-secondary);
  87. cursor: pointer;
  88. font-size: 16px;
  89. transition:
  90. background-color 0.2s ease,
  91. border-color 0.2s ease;
  92. }
  93. .theme-toggle:hover {
  94. background: var(--border-color);
  95. }
  96. .theme-toggle span {
  97. line-height: 1;
  98. }
  99. `}</style>
  100. </>
  101. )
  102. }