Skip to content

Commit ba2bea0

Browse files
committed
feat: add dark/light theme toggle to navigation
- add ThemeToggle client component using Material Symbols dark_mode/light_mode icons - switch Tailwind v4 dark variant to class-based via @custom-variant - apply saved theme before first paint with an inline script in layout
1 parent ba68536 commit ba2bea0

4 files changed

Lines changed: 111 additions & 25 deletions

File tree

‎app/components/Navigation.tsx‎

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Bitcount_Grid_Double } from 'next/font/google';
22
import Link from 'next/link';
3+
import { ThemeToggle } from './ThemeToggle';
34

45

56
const getBitcountGridDouble = Bitcount_Grid_Double({
@@ -18,24 +19,27 @@ export function Navigation() {
1819
>
1920
Junjie Wu
2021
</Link>
21-
<ul className="flex gap-8 text-sm font-poppins font-light">
22-
<li>
23-
<Link
24-
href=""
25-
className="hover:text-zinc-600 dark:hover:text-zinc-400 transition"
26-
>
27-
CV
28-
</Link>
29-
</li>
30-
<li>
31-
<Link
32-
href="mailto:junjwu@whu.edu.cn"
33-
className="hover:text-zinc-600 dark:hover:text-zinc-400 transition"
34-
>
35-
Contact
36-
</Link>
37-
</li>
38-
</ul>
22+
<div className="flex items-center gap-8">
23+
<ul className="flex gap-8 text-sm font-poppins font-light">
24+
<li>
25+
<Link
26+
href=""
27+
className="hover:text-zinc-600 dark:hover:text-zinc-400 transition"
28+
>
29+
CV
30+
</Link>
31+
</li>
32+
<li>
33+
<Link
34+
href="mailto:junjwu@whu.edu.cn"
35+
className="hover:text-zinc-600 dark:hover:text-zinc-400 transition"
36+
>
37+
Contact
38+
</Link>
39+
</li>
40+
</ul>
41+
<ThemeToggle />
42+
</div>
3943
</div>
4044
</nav>
4145
);

‎app/components/ThemeToggle.tsx‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
5+
type Theme = "light" | "dark";
6+
7+
function getInitialTheme(): Theme {
8+
if (typeof document === "undefined") return "light";
9+
return document.documentElement.classList.contains("dark") ? "dark" : "light";
10+
}
11+
12+
export function ThemeToggle() {
13+
// Lazy initializer reads the class the inline script in layout.tsx already set,
14+
// so React's state matches the DOM and hydration stays consistent.
15+
const [theme, setTheme] = useState<Theme>(getInitialTheme);
16+
17+
function toggleTheme() {
18+
const next: Theme = theme === "dark" ? "light" : "dark";
19+
const root = document.documentElement;
20+
21+
root.classList.toggle("dark", next === "dark");
22+
root.style.colorScheme = next;
23+
24+
try {
25+
localStorage.setItem("theme", next);
26+
} catch {
27+
// localStorage can be unavailable (private mode, disabled storage)
28+
}
29+
30+
setTheme(next);
31+
}
32+
33+
const isDark = theme === "dark";
34+
const label = isDark ? "Switch to light theme" : "Switch to dark theme";
35+
36+
return (
37+
<button
38+
type="button"
39+
onClick={toggleTheme}
40+
aria-label={label}
41+
title={label}
42+
className="p-2 -m-2 rounded-full text-zinc-600 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 hover:text-zinc-900 dark:hover:text-zinc-50 transition cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#034f9f]"
43+
>
44+
{isDark ? (
45+
// Material Symbols "light_mode"
46+
<svg
47+
xmlns="http://www.w3.org/2000/svg"
48+
viewBox="0 -960 960 960"
49+
fill="currentColor"
50+
className="size-5"
51+
aria-hidden="true"
52+
>
53+
<path d="M480-360q50 0 85-35t35-85q0-50-35-85t-85-35q-50 0-85 35t-35 85q0 50 35 85t85 35Zm0 80q-83 0-141.5-58.5T280-480q0-83 58.5-141.5T480-680q83 0 141.5 58.5T680-480q0 83-58.5 141.5T480-280ZM200-440H40v-80h160v80Zm720 0H760v-80h160v80ZM440-760v-160h80v160h-80Zm0 720v-160h80v160h-80ZM256-650l-101-97 57-59 96 100-52 56Zm492 496-97-101 53-55 101 97-57 59Zm-98-550 97-101 59 57-100 96-56-52ZM154-212l101-97 55 53-97 101-59-57Zm326-268Z" />
54+
</svg>
55+
) : (
56+
// Material Symbols "dark_mode"
57+
<svg
58+
xmlns="http://www.w3.org/2000/svg"
59+
viewBox="0 -960 960 960"
60+
fill="currentColor"
61+
className="size-5"
62+
aria-hidden="true"
63+
>
64+
<path d="M480-120q-150 0-255-105T120-480q0-150 105-255t255-105q14 0 27.5 1t26.5 3q-41 29-65.5 75.5T444-660q0 90 63 153t153 63q55 0 101-24.5t75-65.5q2 13 3 26.5t1 27.5q0 150-105 255T480-120Zm0-80q88 0 158-48.5T740-375q-20 5-40 8t-40 3q-123 0-209.5-86.5T364-660q0-20 3-40t8-40q-78 32-126.5 102T200-480q0 116 82 198t198 82Zm-10-270Z" />
65+
</svg>
66+
)}
67+
</button>
68+
);
69+
}

‎app/globals.css‎

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,19 @@
11
@import "tailwindcss";
22

3+
/* Tailwind v4: make `dark:` utilities respond to a `.dark` class on <html>
4+
instead of only the OS-level prefers-color-scheme media query. */
5+
@custom-variant dark (&:where(.dark, .dark *));
6+
37
:root {
48
--background: #ffffff;
59
--foreground: #171717;
10+
color-scheme: light;
11+
}
12+
13+
.dark {
14+
--background: #0a0a0a;
15+
--foreground: #ededed;
16+
color-scheme: dark;
617
}
718

819
@theme inline {
@@ -12,13 +23,6 @@
1223
--font-mono: var(--font-geist-mono);
1324
}
1425

15-
@media (prefers-color-scheme: dark) {
16-
:root {
17-
--background: #0a0a0a;
18-
--foreground: #ededed;
19-
}
20-
}
21-
2226
body {
2327
background: var(--background);
2428
color: var(--foreground);

‎app/layout.tsx‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,17 @@ export default function RootLayout({
2525
return (
2626
<html
2727
lang="en"
28+
suppressHydrationWarning
2829
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
2930
>
31+
<head>
32+
{/* Applies the saved theme before first paint to avoid a flash of the wrong theme. */}
33+
<script
34+
dangerouslySetInnerHTML={{
35+
__html: `(function(){try{var t=localStorage.getItem("theme");var d=t?t==="dark":window.matchMedia("(prefers-color-scheme: dark)").matches;var e=document.documentElement;e.classList.toggle("dark",d);e.style.colorScheme=d?"dark":"light";}catch(e){}})()`,
36+
}}
37+
/>
38+
</head>
3039
<body className="min-h-full flex flex-col">{children}</body>
3140
</html>
3241
);

0 commit comments

Comments
 (0)