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:
parent
b99b53d03e
commit
3ee1974021
3 changed files with 40 additions and 48 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue