Skip to content

代码片段

主题切换

ts
import { useLocalStorage } from "foxact/use-local-storage"
import { useEffect, useMemo, useSyncExternalStore } from "react"

const query = "(prefers-color-scheme: dark)"

function getSnapshot() {
  return window.matchMedia(query).matches
}

function getServerSnapshot(): undefined {
  return undefined
}

function subscribe(callback: () => void) {
  const matcher = window.matchMedia(query)
  matcher.addEventListener("change", callback)
  return () => {
    matcher.removeEventListener("change", callback)
  }
}

function useSystemDark() {
  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
}

const themeOptions = ["system", "light", "dark"] as const
export type Theme = (typeof themeOptions)[number]

function isDarkMode(setting?: Theme | null, isSystemDark?: boolean) {
  return setting === "dark" || (isSystemDark && setting !== "light")
}

export function useDark(themeKey = "use-dark") {
  const [theme, setTheme] = useLocalStorage<Theme>(themeKey, "system")
  const isSystemDark = useSystemDark()

  const isDark = useMemo(
    () => isDarkMode(theme, isSystemDark),
    [isSystemDark, theme],
  )

  const toggleDark = () => {
    if (theme === "system") {
      setTheme(isSystemDark ? "light" : "dark")
    } else {
      setTheme("system")
    }
  }

  useEffect(() => {
    const isDark = isDarkMode(theme, isSystemDark)
    if (isDark) {
      document.documentElement.classList.toggle("dark", true)
    } else {
      document.documentElement.classList.toggle("dark", false)
    }

    if (
      (theme === "dark" && isSystemDark) ||
      (theme === "light" && !isSystemDark)
    ) {
      setTheme("system")
    }
  }, [theme, isSystemDark, setTheme])

  return { isDark, toggleDark }
}
tsx
"use client"

import { useDark } from "~/hooks/use-dark"

export function AppearanceSwitch({ className = "" }: { className?: string }) {
  const { toggleDark } = useDark()

  return (
    <button type="button" onClick={toggleDark} className={"flex " + className}>
      <div className="i-lucide-sun scale-100 dark:scale-0 transition-transform duration-500 rotate-0 dark:-rotate-90" />
      <div className="i-lucide-moon absolute scale-0 dark:scale-100 transition-transform duration-500 rotate-90 dark:rotate-0" />
      <span className="sr-only">Toggle theme</span>
    </button>
  )
}
ts
/**
 * (Optional) Add transition animation to dark mode switch
 * Credit to [@hooray](https://github.com/hooray)
 * @see https://github.com/vuejs/vitepress/pull/2347
 */
export function transitionDark(
  event: MouseEvent,
  isDark: boolean,
  toggleDark: () => void,
) {
  const isAppearanceTransition =
    // @ts-expect-error experimental API
    document.startViewTransition &&
    !window.matchMedia("(prefers-reduced-motion: reduce)").matches

  if (!isAppearanceTransition) {
    toggleDark()
    return
  }

  const x = event.clientX
  const y = event.clientY
  const endRadius = Math.hypot(
    Math.max(x, innerWidth - x),
    Math.max(y, innerHeight - y),
  )

  const transition = document.startViewTransition(async () => {
    return new Promise((resolve) => {
      resolve(toggleDark())
    })
  })
  void transition.ready.then(() => {
    const clipPath = [
      `circle(0px at ${x}px ${y}px)`,
      `circle(${endRadius}px at ${x}px ${y}px)`,
    ]
    document.documentElement.animate(
      {
        clipPath: isDark ? [...clipPath].reverse() : clipPath,
      },
      {
        duration: 400,
        easing: "ease-out",
        pseudoElement: isDark
          ? "::view-transition-old(root)"
          : "::view-transition-new(root)",
      },
    )
  })
}
css
html.dark {
  color-scheme: dark;
}

/* Optional */
::view-transition-old(root),
::view-transition-new(root) {
  animation: none;
  mix-blend-mode: normal;
}
::view-transition-old(root) {
  z-index: 1;
}
::view-transition-new(root) {
  z-index: 9999;
}
.dark::view-transition-old(root) {
  z-index: 9999;
}
.dark::view-transition-new(root) {
  z-index: 1;
}

处理首次加载闪烁

html
<script>
  !(function () {
    var e =
        window.matchMedia &&
        window.matchMedia("(prefers-color-scheme: dark)").matches,
      t = localStorage.getItem("use-dark") || "system"
    ;('"dark"' === t || (e && '"light"' !== t)) &&
      document.documentElement.classList.toggle("dark", !0)
  })()
</script>
tsx
// 同时需要忽略水合错误
export function ThemeProvider({ children }: { children: React.ReactNode }) {
  return (
    <>
      <script
        id="change-theme"
        dangerouslySetInnerHTML={{
          __html: `!(function () {
            var e =
                window.matchMedia &&
                window.matchMedia("(prefers-color-scheme: dark)").matches,
              t = localStorage.getItem("use-dark") || "system";
            ('"dark"' === t || (e && '"light"' !== t)) &&
              document.documentElement.classList.toggle("dark", !0);
          })();`,
        }}
      ></script>
      {children}
    </>
  )
}

屏幕断点标识

tsx
import { useEffect, useState } from "react"

export function TailwindIndicator() {
  const [show, setShow] = useState(false)
  useEffect(() => {
    let timeout: number | null
    const handleResize = () => {
      if (timeout) clearTimeout(timeout)
      setShow(true)
      timeout = window.setTimeout(() => {
        setShow(false)
      }, 2000)
    }
    window.addEventListener("resize", handleResize)

    return () => {
      window.removeEventListener("resize", handleResize)
      if (timeout) clearTimeout(timeout)
    }
  }, [])

  if (import.meta.env.PROD) return null

  return (
    <div
      className={`fixed bottom-1 left-1 z-50 h-6 w-6 p-3
      font-mono text-xs text-white bg-gray-800 rounded-full
      ${show ? "flex items-center justify-center" : "hidden"}`}
    >
      <div className="block sm:hidden">xs</div>
      <div className="hidden sm:block md:hidden">sm</div>
      <div className="hidden md:block lg:hidden">md</div>
      <div className="hidden lg:block xl:hidden">lg</div>
      <div className="hidden xl:block 2xl:hidden">xl</div>
      <div className="hidden 2xl:block">2xl</div>
    </div>
  )
}