feat(theme): light/dark/system toggle with no first-paint flash

This commit is contained in:
Dave 2026-09-16 09:59:46 +02:00
parent 99b465e402
commit a839c4f5ad
3 changed files with 98 additions and 1 deletions

View file

@ -1,6 +1,15 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { ThemeScript } from "@/components/theme/theme-script";
import "./globals.css";
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
display: "swap",
});
export const metadata: Metadata = {
title: "Client Board",
description: "Project tracking for client work",
@ -10,7 +19,12 @@ export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en">
// suppressHydrationWarning: ThemeScript mutates the class on <html> before
// React hydrates, so server and client markup differ here by design.
<html lang="en" className={inter.variable} suppressHydrationWarning>
<head>
<ThemeScript />
</head>
<body>{children}</body>
</html>
);

View file

@ -0,0 +1,23 @@
/**
* Applies the stored theme before first paint.
*
* This has to be a blocking inline script in <head>. A React effect runs after
* hydration, by which point the browser has already painted the default theme
* and the user sees a white flash on every navigation to a dark board. There
* is no way to fix that from inside React.
*/
const SCRIPT = `
(function () {
try {
var stored = localStorage.getItem('theme');
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var dark = stored === 'dark' || (stored !== 'light' && prefersDark);
document.documentElement.classList.toggle('dark', dark);
document.documentElement.style.colorScheme = dark ? 'dark' : 'light';
} catch (e) {}
})();
`;
export function ThemeScript() {
return <script dangerouslySetInnerHTML={{ __html: SCRIPT }} />;
}

View file

@ -0,0 +1,60 @@
"use client";
import { useEffect, useState } from "react";
type Theme = "light" | "dark" | "system";
function apply(theme: Theme) {
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
const dark = theme === "dark" || (theme === "system" && prefersDark);
document.documentElement.classList.toggle("dark", dark);
document.documentElement.style.colorScheme = dark ? "dark" : "light";
if (theme === "system") localStorage.removeItem("theme");
else localStorage.setItem("theme", theme);
}
const OPTIONS: Array<{ value: Theme; label: string }> = [
{ value: "light", label: "Light" },
{ value: "system", label: "Auto" },
{ value: "dark", label: "Dark" },
];
export function ThemeToggle() {
const [theme, setTheme] = useState<Theme>("system");
useEffect(() => {
const stored = localStorage.getItem("theme");
if (stored === "light" || stored === "dark") setTheme(stored);
}, []);
function choose(next: Theme) {
setTheme(next);
apply(next);
}
return (
<div
role="radiogroup"
aria-label="Colour theme"
className="inline-flex rounded-lg border border-border-subtle bg-surface p-0.5"
>
{OPTIONS.map((option) => (
<button
key={option.value}
role="radio"
aria-checked={theme === option.value}
onClick={() => choose(option.value)}
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
theme === option.value
? "bg-panel text-text-primary shadow-[var(--shadow-tile)]"
: "text-text-muted hover:text-text-secondary"
}`}
>
{option.label}
</button>
))}
</div>
);
}