feat(theme): light/dark/system toggle with no first-paint flash
This commit is contained in:
parent
99b465e402
commit
a839c4f5ad
3 changed files with 98 additions and 1 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
23
src/components/theme/theme-script.tsx
Normal file
23
src/components/theme/theme-script.tsx
Normal 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 }} />;
|
||||
}
|
||||
60
src/components/theme/theme-toggle.tsx
Normal file
60
src/components/theme/theme-toggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue