CSS-only theme icon toggle, iOS status bar theme-color

- Theme icon now uses dark:hidden/dark:block CSS classes instead of
  JS state, eliminating the flash on hydration entirely
- Remove useSyncExternalStore mounted pattern (no longer needed)
- Add theme-color meta tag updated by blocking script and toggle
  for iOS Safari status bar
- Revert body fade-in animation (not needed with CSS-only icons)
This commit is contained in:
Lawrence Chen 2026-02-10 01:27:15 -08:00
parent b99b53d03e
commit 3ee1974021
3 changed files with 40 additions and 48 deletions

View file

@ -33,12 +33,6 @@ html {
body {
background: var(--background);
color: var(--foreground);
animation: fadeIn 150ms ease-in forwards;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
::selection {

View file

@ -75,9 +75,10 @@ export default function RootLayout({
return (
<html lang="en" suppressHydrationWarning>
<head>
<meta name="theme-color" content="#0a0a0a" />
<script
dangerouslySetInnerHTML={{
__html: `(function(){try{var t=localStorage.getItem("theme");if(t==="light")return;if(t==="system"&&window.matchMedia("(prefers-color-scheme:light)").matches)return;document.documentElement.classList.add("dark")}catch(e){}})()`,
__html: `(function(){try{var t=localStorage.getItem("theme");var light=t==="light"||(t==="system"&&window.matchMedia("(prefers-color-scheme:light)").matches);if(!light)document.documentElement.classList.add("dark");var m=document.querySelector('meta[name="theme-color"]');if(m)m.content=light?"#fafafa":"#0a0a0a"}catch(e){}})()`,
}}
/>
</head>

View file

@ -1,67 +1,52 @@
"use client";
import { useTheme } from "next-themes";
import { useSyncExternalStore } from "react";
import { flushSync } from "react-dom";
const subscribe = () => () => {};
const getSnapshot = () => true;
const getServerSnapshot = () => false;
export function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme();
const mounted = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const toggle = () => {
const next = resolvedTheme === "dark" ? "light" : "dark";
const apply = () => {
setTheme(next);
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.setAttribute("content", next === "dark" ? "#0a0a0a" : "#fafafa");
};
if (
!document.startViewTransition ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
) {
setTheme(next);
apply();
return;
}
document.startViewTransition(() => {
flushSync(() => {
setTheme(next);
});
flushSync(apply);
});
};
const isDark = mounted ? resolvedTheme === "dark" : true;
return (
<button
onClick={toggle}
className="inline-flex h-9 w-9 items-center justify-center text-muted hover:text-foreground transition-colors cursor-pointer"
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
aria-label="Toggle theme"
>
<ThemeIcon mounted={mounted} isDark={isDark} />
</button>
);
}
// Render a stable SVG subtree so theme flips don't unmount/mount icon nodes.
export function ThemeIcon({ mounted, isDark }: { mounted: boolean; isDark: boolean }) {
const base = "transition-opacity";
const shown = "opacity-100";
const hidden = "opacity-0";
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className={mounted ? shown : hidden}
>
<g data-icon="sun" className={`${base} ${isDark ? shown : hidden}`}>
{/* Sun icon — visible in dark mode, hidden in light mode */}
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="hidden dark:block"
aria-hidden="true"
>
<circle cx="12" cy="12" r="5" />
<line x1="12" y1="1" x2="12" y2="3" />
<line x1="12" y1="21" x2="12" y2="23" />
@ -71,10 +56,22 @@ export function ThemeIcon({ mounted, isDark }: { mounted: boolean; isDark: boole
<line x1="21" y1="12" x2="23" y2="12" />
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
</g>
<g data-icon="moon" className={`${base} ${isDark ? hidden : shown}`}>
</svg>
{/* Moon icon — visible in light mode, hidden in dark mode */}
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="block dark:hidden"
aria-hidden="true"
>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</g>
</svg>
</svg>
</button>
);
}