Fix theme initialization and hydration issues

This commit is contained in:
Janpeter Visser 2026-04-19 09:44:23 +02:00
parent d0739736aa
commit c3cd1de647
4 changed files with 204 additions and 22 deletions

View file

@ -32,11 +32,10 @@ export default function RootLayout({
<html
lang="nl"
suppressHydrationWarning
className={`${fontBody.variable} ${fontMono.variable}`}
className={`${fontBody.variable} ${fontMono.variable} dark`}
>
<body className="min-h-screen antialiased">
<ThemeProvider
attribute="class"
defaultTheme="dark"
enableSystem
disableTransitionOnChange

View file

@ -1,7 +1,7 @@
"use client";
import { MoonStarIcon, MonitorCogIcon, SunMediumIcon } from "lucide-react";
import { useTheme } from "next-themes";
import { useTheme } from "@/components/theme-provider";
import {
DropdownMenu,
DropdownMenuContent,
@ -17,25 +17,13 @@ const themeOptions = [
{ value: "system", label: "System", icon: MonitorCogIcon },
] as const;
function getThemeIcon(theme: string | undefined) {
if (theme === "light") {
return <SunMediumIcon className="size-4" />;
}
if (theme === "dark") {
return <MoonStarIcon className="size-4" />;
}
return <MonitorCogIcon className="size-4" />;
}
export function ThemeMenu() {
const { theme, setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger aria-label="Thema kiezen">
{getThemeIcon(theme)}
<MonitorCogIcon className="size-4" />
Theme
</DropdownMenuTrigger>
<DropdownMenuContent>

View file

@ -1,10 +1,205 @@
"use client";
import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
type ThemeProviderProps = React.ComponentProps<typeof NextThemesProvider>;
const STORAGE_KEY = "inspannings-monitor-theme";
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
export type ThemeName = "light" | "dark" | "system";
export type ResolvedThemeName = "light" | "dark";
type ThemeContextValue = {
theme: ThemeName;
setTheme: (theme: ThemeName) => void;
resolvedTheme: ResolvedThemeName;
systemTheme: ResolvedThemeName;
themes: ThemeName[];
};
type ThemeProviderProps = {
children: React.ReactNode;
defaultTheme?: ThemeName;
enableSystem?: boolean;
disableTransitionOnChange?: boolean;
};
const ThemeContext = React.createContext<ThemeContextValue | null>(null);
const AVAILABLE_THEMES: ThemeName[] = ["light", "dark", "system"];
function getSystemTheme(): ResolvedThemeName {
if (typeof window === "undefined") {
return "dark";
}
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
function readStoredTheme(
defaultTheme: ThemeName,
enableSystem: boolean,
): ThemeName {
if (typeof window === "undefined") {
return defaultTheme;
}
const storedTheme = window.localStorage.getItem(STORAGE_KEY);
if (storedTheme === "light" || storedTheme === "dark") {
return storedTheme;
}
if (storedTheme === "system" && enableSystem) {
return storedTheme;
}
return defaultTheme;
}
function applyResolvedTheme(resolvedTheme: ResolvedThemeName) {
const root = document.documentElement;
root.classList.remove("light", "dark");
root.classList.add(resolvedTheme);
root.dataset.theme = resolvedTheme;
root.style.colorScheme = resolvedTheme;
}
function withDisabledTransitions(action: () => void) {
const style = document.createElement("style");
style.appendChild(
document.createTextNode(
"*{-webkit-transition:none!important;transition:none!important}",
),
);
document.head.appendChild(style);
action();
window.getComputedStyle(document.body);
requestAnimationFrame(() => {
document.head.removeChild(style);
});
}
export function ThemeProvider({
children,
defaultTheme = "dark",
enableSystem = true,
disableTransitionOnChange = false,
}: ThemeProviderProps) {
const initialSystemTheme = React.useMemo(() => getSystemTheme(), []);
const [theme, setThemeState] = React.useState<ThemeName>(() =>
typeof window === "undefined"
? defaultTheme
: readStoredTheme(defaultTheme, enableSystem),
);
const [systemTheme, setSystemTheme] =
React.useState<ResolvedThemeName>(initialSystemTheme);
const [resolvedTheme, setResolvedTheme] =
React.useState<ResolvedThemeName>(() => {
if (typeof window === "undefined") {
return defaultTheme === "light" ? "light" : "dark";
}
const currentTheme = readStoredTheme(defaultTheme, enableSystem);
return currentTheme === "system" ? getSystemTheme() : currentTheme;
});
React.useEffect(() => {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
function syncTheme(nextTheme: ThemeName) {
const nextSystemTheme = getSystemTheme();
const nextResolvedTheme =
nextTheme === "system" ? nextSystemTheme : nextTheme;
const apply = () => applyResolvedTheme(nextResolvedTheme);
if (disableTransitionOnChange) {
withDisabledTransitions(apply);
} else {
apply();
}
document.documentElement.dataset.themePreference = nextTheme;
setSystemTheme(nextSystemTheme);
setResolvedTheme(nextResolvedTheme);
}
syncTheme(theme);
function handleStorage(event: StorageEvent) {
if (event.key !== STORAGE_KEY) {
return;
}
const nextTheme =
event.newValue === "light" ||
event.newValue === "dark" ||
(event.newValue === "system" && enableSystem)
? (event.newValue as ThemeName)
: defaultTheme;
setThemeState(nextTheme);
syncTheme(nextTheme);
}
function handleSystemThemeChange() {
const nextSystemTheme = getSystemTheme();
setSystemTheme(nextSystemTheme);
if (theme === "system") {
const apply = () => applyResolvedTheme(nextSystemTheme);
if (disableTransitionOnChange) {
withDisabledTransitions(apply);
} else {
apply();
}
setResolvedTheme(nextSystemTheme);
}
}
window.addEventListener("storage", handleStorage);
mediaQuery.addEventListener("change", handleSystemThemeChange);
return () => {
window.removeEventListener("storage", handleStorage);
mediaQuery.removeEventListener("change", handleSystemThemeChange);
};
}, [defaultTheme, disableTransitionOnChange, enableSystem, theme]);
const setTheme = React.useCallback(
(nextTheme: ThemeName) => {
setThemeState(nextTheme);
window.localStorage.setItem(STORAGE_KEY, nextTheme);
},
[],
);
const contextValue = React.useMemo<ThemeContextValue>(
() => ({
theme,
setTheme,
resolvedTheme,
systemTheme,
themes: enableSystem ? AVAILABLE_THEMES : ["light", "dark"],
}),
[enableSystem, resolvedTheme, setTheme, systemTheme, theme],
);
return (
<ThemeContext.Provider value={contextValue}>{children}</ThemeContext.Provider>
);
}
export function useTheme() {
const context = React.useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used within ThemeProvider");
}
return context;
}

View file

@ -7,7 +7,7 @@ import {
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react";
import { useTheme } from "next-themes";
import { useTheme } from "@/components/theme-provider";
import { Toaster as Sonner, type ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => {
@ -15,7 +15,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
return (
<Sonner
theme={resolvedTheme === "dark" ? "dark" : "light"}
theme={resolvedTheme === "light" ? "light" : "dark"}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,