Diagnose and fix broken light/dark mode theme switching in Next.js apps. Use when: theme toggle has no visual effect, light mode shows dark colors, page flashes on refresh, code blocks ignore theme, or CSS variables don't apply after switching. Covers anti-flash FOUC prevention, CSS specificity for html[data-theme], theme-aware CSS variables, lazy useState pattern, and GitHub Action CI for theme contracts. Triggers on: "light mode broken", "dark mode not working", "theme toggle broken", "FOUC", "flash on refresh", "theme not switching", "data-theme not applying", "code blocks wrong color", "theme flicker", "الوضع النهاري معطل", "الوضع المظلم لا يعمل", "زر التبديل لا يعمل".
Resources
1Install
npx skillscat add bakabala27-svg/naas-agentic-core/nextjs-theme-switching Install via the SkillsCat registry.
Next.js Theme Switching
Core law: A theme switch is only working when ALL of these are true:
- No flash on page refresh (FOUC-free)
- Every element changes color — including code blocks
- The correct theme loads instantly on first paint, not after hydration
1. Quick Diagnosis (run first)
# 1. Anti-flash file exists
ls frontend/public/theme-init.js
# Expected: file exists
# 2. layout.jsx loads it synchronously in <head>
grep "theme-init.js" frontend/app/layout.jsx
# Expected: <script src="/theme-init.js" /> (no async)
# 3. CSS selectors
grep "html\[data-theme" frontend/app/globals.css
# Expected: ≥2 results (one for light, one for dark)
# 4. No duplicate body/html blocks (Turbopack bug)
grep -c "^body {" frontend/app/globals.css
# Expected: 1 (not 2+)
# 5. Hard-coded colors in code blocks
grep -A8 "\.markdown-content pre" frontend/app/globals.css | grep "#[0-9a-f]\{6\}"
# Expected: no output (should use var(--pre-bg))
# 6. Lazy useState
grep "useState(() =>" frontend/app/components/*.jsx
# Expected: present for theme state
# 7. Production HTML verification (most reliable)
npm run build 2>/dev/null
grep "theme-init.js" frontend/.next/server/app/index.html
# Expected: <script src="/theme-init.js"> inside <head>Healthy state:
frontend/public/theme-init.jsexists and is served at/theme-init.jslayout.jsxhas<script src="/theme-init.js">(synchronous, no async) in<head>html[data-theme='light']andhtml[data-theme='dark']both exist in CSShtmlandbodyeach have ONE CSS block containing all properties- Code block colors use
var(--pre-bg)/var(--code-bg), not hex literals useStatefor theme uses a lazy initializer, notuseState('dark')
2. Root Cause Map
| Symptom | Root Cause | Fix |
|---|---|---|
| Theme toggle has no effect | html[data-theme] CSS selector missing |
Add html[data-theme='light/dark'] as first selector in theme blocks |
| Flash of wrong theme on refresh | No anti-flash script in <head> |
Create public/theme-init.js + <script src="/theme-init.js"> in layout |
Anti-flash script in <body> not <head> |
Next.js 16 moves dangerouslySetInnerHTML from <head> to <body> |
Use external file in /public instead |
body background/color missing from compiled CSS |
Turbopack drops properties when same element has multiple CSS blocks | Merge all body properties into one body { } block |
| Code blocks stay dark in light mode | Hard-coded hex colors (#0f172a) |
Replace with var(--pre-bg) CSS variable |
| Brief flash before correct theme | useState('dark') + useEffect pattern |
Use lazy useState(() => localStorage...) |
| Theme applies to some elements, not all | body doesn't inherit from html |
Set background/color explicitly on body using CSS variables |
| Browser scrollbars ignore theme | Missing colorScheme |
Set document.documentElement.style.colorScheme = theme |
| Dev server shows old CSS after file change | Turbopack cache stale | rm -rf .next then restart dev server |
3. The Four Fixes
Fix 1 — Anti-flash script via /public file
⚠️ Next.js 16 App Router critical finding (verified live 2026-05-14):
<script dangerouslySetInnerHTML>in<head>JSX → Next.js 16 moves it to<body>. Does NOT work.next/script strategy="beforeInteractive"→ executes via__next_spayload after runtime. Does NOT work.- Only reliable solution: external file in
/public+<script src>in<head>.
Step 1 — Create frontend/public/theme-init.js:
(function () {
try {
var t = localStorage.getItem('theme') || 'dark';
var r = document.documentElement;
r.dataset.theme = t;
r.style.colorScheme = t;
if (document.body) {
document.body.dataset.theme = t;
} else {
var o = new MutationObserver(function () {
if (document.body) { document.body.dataset.theme = t; o.disconnect(); }
});
o.observe(r, { childList: true });
}
} catch (e) {}
})();Step 2 — Load it synchronously in frontend/app/layout.jsx:
export default function RootLayout({ children }) {
return (
<html lang="ar" dir="rtl" suppressHydrationWarning>
<head>
{/* eslint-disable-next-line @next/next/no-sync-scripts */}
<script src="/theme-init.js" /> {/* NO async — must be synchronous */}
</head>
<body suppressHydrationWarning>{children}</body>
</html>
);
}Why suppressHydrationWarning: The script mutates html.dataset.theme before React hydrates. Without this prop, React throws a hydration mismatch warning.
Verify it works — check production HTML:
npm run build
grep "theme-init.js" .next/server/app/index.html
# Expected: <script src="/theme-init.js"> inside <head>Fix 2 — CSS selector specificity
JS sets data-theme on document.documentElement (the html element). CSS must target html[data-theme] — not just [data-theme].
/* WRONG — misses html element specificity */
[data-theme='light'],
body[data-theme='light'] { ... }
/* CORRECT — html[data-theme] first, highest specificity */
html[data-theme='light'],
body[data-theme='light'],
[data-theme='light'] {
--bg-color: #ffffff;
--text-color: #0a0a0a;
--pre-bg: #f8f8f8;
--pre-color: #1e1e1e;
--code-bg: #f4f4f5;
--code-color: #18181b;
/* ... all other variables */
}
html[data-theme='dark'],
body[data-theme='dark'],
[data-theme='dark'] {
--bg-color: #0a0a0a;
--text-color: #fafafa;
--pre-bg: #0f172a;
--pre-color: #e2e8f0;
--code-bg: #1e1e2e;
--code-color: #cdd6f4;
/* ... all other variables */
}Also split body, html { ... } into separate rules. Critical Turbopack warning:
⚠️ Turbopack CSS merging bug (verified live 2026-05-14):
Multiple blocks targeting the same element get merged — later properties win, earlier ones are dropped.html, body { overflow-x: hidden }+body { background: var(--bg-color) }= Turbopack keeps onlybody { overflow-x: hidden; max-width: 100vw }and dropsbackgroundandcolor.
WRONG — Turbopack drops background/color:
html, body { overflow-x: hidden; max-width: 100vw; }
body { background: var(--bg-color); color: var(--text-color); } /* DROPPED */CORRECT — one block per element, all properties together:
html {
overflow-x: hidden;
max-width: 100vw;
background: var(--bg-color);
color: var(--text-color);
transition: background-color 0.3s ease, color 0.3s ease;
}
body {
overflow-x: hidden;
max-width: 100vw;
background: var(--bg-color); /* explicit — body doesn't always inherit background */
color: var(--text-color);
transition: background-color 0.3s ease, color 0.3s ease;
}Verify after build:
npm run build
python3 -c "
import re
css = open('.next/server/app/index.html').read()
# or check the compiled CSS file
print('body has background:', 'background:var(--bg-color)' in css.replace(' ',''))
"Fix 3 — CSS variables for code blocks
Never use hard-coded hex colors for elements that must change with the theme.
/* WRONG */
.markdown-content pre {
background: #0f172a; /* always dark */
color: #e2e8f0;
}
/* CORRECT */
.markdown-content pre {
background: var(--pre-bg);
color: var(--pre-color);
border: 1px solid var(--pre-border);
}
.markdown-content code {
background: var(--code-bg);
color: var(--code-color);
border: 1px solid var(--code-border);
}
/* Reset code inside pre — inherits from pre */
.markdown-content pre code {
background: transparent;
color: inherit;
border: none;
}Fix 4 — Lazy useState initializer
// WRONG — causes double-render flash
const [theme, setTheme] = useState('dark');
useEffect(() => {
const stored = localStorage.getItem('theme');
setTheme(stored === 'light' ? 'light' : 'dark');
}, []);
// CORRECT — reads localStorage on first render, no flash
const [theme, setTheme] = useState(() => {
if (typeof window === 'undefined') return 'dark'; // SSR guard
return localStorage.getItem('theme') === 'light' ? 'light' : 'dark';
});The useEffect for reading initial theme is no longer needed. Keep only the effect that applies the theme to the DOM:
useEffect(() => {
const root = document.documentElement;
root.dataset.theme = theme;
root.style.colorScheme = theme;
if (document.body) document.body.dataset.theme = theme;
localStorage.setItem('theme', theme);
}, [theme]);4. Light Mode Luxury Overrides
After fixing the mechanics, add light-mode-specific polish. Use html[data-theme='light'] scoped rules at the end of the CSS file:
/* Header — subtle separator */
html[data-theme='light'] .header {
border-bottom: 1px solid var(--border-color);
box-shadow: 0 1px 3px rgb(0 0 0 / 0.04);
}
/* Input — visible border + focus ring */
html[data-theme='light'] .input-area {
border: 1.5px solid var(--border-color);
box-shadow: 0 2px 8px rgb(0 0 0 / 0.05);
}
html[data-theme='light'] .input-area:focus-within {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.10);
}
/* Scrollbar */
html[data-theme='light'] ::-webkit-scrollbar-thumb {
background: #d4d4d8;
}
html[data-theme='light'] ::-webkit-scrollbar-thumb:hover {
background: #a1a1aa;
}Read references/light-mode-overrides.md for the full list of recommended overrides.
5. GitHub Action CI
Add .github/workflows/frontend-theme-ci.yml to enforce theme contracts on every PR touching frontend/.
Read references/github-action-template.md for the complete 5-job workflow template.
Critical jobs:
theme-contracts— verifieshtml[data-theme]selectors and CSS variables existanti-flash-gate— verifies anti-flash script and lazy useState are presentbuild-check— verifies Next.js builds without errors
6. Invariants (never break these)
- Anti-flash via
/publicfile — in Next.js 16 App Router,dangerouslySetInnerHTMLin<head>JSX gets moved to<body>. Usefrontend/public/theme-init.js+<script src="/theme-init.js">. html[data-theme]must be the first selector in every theme block. JS targetsdocumentElement, notbody.- Code block colors must use CSS variables — never hard-coded hex. Any element that must change with the theme must use
var(--*). - Lazy
useStatefor theme — neveruseState('dark') + useEffect. The lazy initializer readslocalStoragesynchronously on first render. bodyneeds explicitbackground— it does not reliably inheritbackgroundfromhtmlvia CSS custom properties in all browsers.- One CSS block per element (Turbopack) — never split
htmlorbodyproperties across multiple blocks. Turbopack merges them and drops earlier properties. Allhtmlproperties in onehtml { }block, allbodyproperties in onebody { }block. - Always verify with production build — dev server may serve cached CSS. Run
npm run buildand check.next/server/app/index.htmlfor ground truth. - Theme toggle button must be always visible (ISS-067) — never hide the theme toggle inside a dropdown menu. Place a dedicated button (
header-theme-btn) directly in the header, outside any conditional render block. :rootmust contain ALL CSS variables (ISS-067) — even ifhtml[data-theme]blocks define them,:rootmust have safe fallback values for every variable. Missing:rootfallbacks causeundefinedvariables before theme is applied.
7. CI Gate: theme-button-gate (ISS-067)
The theme-button-gate job in frontend-theme-ci.yml enforces invariants 8 and 9:
- name: Verify theme button is outside dropdown
shell: bash
run: |
theme_line=$(grep -n "header-theme-btn" frontend/app/components/CogniForgeApp.jsx \
| head -1 | cut -d: -f1)
menu_line=$(grep -n "isMenuOpen &&" frontend/app/components/CogniForgeApp.jsx \
| head -1 | cut -d: -f1)
if [ "$theme_line" -ge "$menu_line" ]; then
echo "❌ Theme button must appear BEFORE dropdown"
exit 1
fiThis prevents regression where the button gets moved back inside the dropdown.
References
references/light-mode-overrides.md— full list of light mode luxury CSS overrides (header, input, sidebar, scrollbar, blockquote, math tables)references/github-action-template.md— completefrontend-theme-ci.ymlwith 6 jobs and all verification steps (updated for ISS-067: theme-button-gate +:rootfallback check)