How to Implement Page Theme Locks to Prevent Light/Dark Flips in Next.js
A page theme lock guarantees a single color scheme for the entire page by setting one theme class on the root element and preventing any child component from overriding it.
The Taste-Skill repository defines this rule as a core specification for visual consistency, requiring that a page has exactly one theme and sections never invert mid-page. When you implement page theme locks to prevent light/dark flips, you eliminate jarring palette switches that break user trust and violate accessibility standards.
What Is a Page Theme Lock?
A page theme lock is an architectural rule that enforces a single colour scheme—light, dark, or auto—for an entire page. According to the Taste-Skill specification in skills/taste-skill/SKILL.md §11.1, "The page has ONE theme. Sections do not invert." This guarantee is also recorded in CHANGELOG.md under the Page Theme Lock entry, which states: "one theme (light / dark / auto) for the whole page; no mid-page light/dark flips."
Why Page Theme Locks Matter
Page theme locks are not stylistic preferences; they are structural requirements that protect user experience.
- Visual continuity – Users should never feel they have entered a different site mid-scroll.
- Brand fidelity – The same accent colour and typography must appear consistently across every section.
- Accessibility – A single contrast map ensures WCAG AA/AAA checks remain valid in both modes.
How to Implement a Page Theme Lock
Choose Your Theme Strategy
Decide on one of three strategies before writing any markup:
- Auto – Respect the user’s system preference via
prefers-color-scheme. - Explicit Light – Add a
lightclass (or omit thedarkclass) to the root element. - Explicit Dark – Add a
darkclass on the root element.
Set the Theme Once at the Page Root
In a Next.js project, the ideal place is app/layout.tsx. The component must be a client component ("use client"), read the preferred mode (or a user-selected toggle), and apply the appropriate class to the <html> element. No child component should ever change the theme after this initial set.
Use a Single Theming System
Pick one approach and use it everywhere:
- Tailwind v4 – Rely on the
dark:variant for colour utilities. - CSS variables – Recommended when the design system (e.g., Radix Themes or shadcn/ui) already provides a
<Theme>component. Define semantic tokens such as--surfaceand--text-primary, then swap them under[data-theme="dark"]or@media (prefers-color-scheme: dark).
Prevent Accidental Overrides
Guard against common mistakes that break the lock:
- Do not set background colours that belong to a different theme family (e.g.,
bg-amber-50inside a dark page). - Avoid per-section
<Theme>orclassName="dark"props. - If the brief explicitly requests a one-time "Color Block Story" or a full-page theme switch, enforce it once with a clear transition and document the exception.
Code Examples
Next.js app/layout.tsx (Single-Theme Lock)
"use client";
import { useEffect, useState } from "react";
export default function RootLayout({
children,
}: { children: React.ReactNode }) {
// 0 = auto, 1 = light, 2 = dark
const [mode, setMode] = useState<number>(0);
useEffect(() => {
const root = document.documentElement;
// Choose mode: 0 = auto, 1 = light, 2 = dark
if (mode === 1) {
root.classList.remove("dark");
root.setAttribute("data-theme", "light");
} else if (mode === 2) {
root.classList.add("dark");
root.setAttribute("data-theme", "dark");
} else {
// Auto – remove explicit classes, let prefers-color-scheme decide
root.classList.remove("dark");
root.removeAttribute("data-theme");
}
}, [mode]);
// Optional UI toggle (shown only if the brief asks for it)
const toggle = () => setMode((prev) => (prev + 1) % 3);
return (
<html lang="en">
<head />
<body className="bg-white dark:bg-zinc-950 text-gray-900 dark:text-gray-100">
{/* Example toggle – remove if not needed */}
<button onClick={toggle} className="fixed top-4 right-4">
Theme: {mode === 0 ? "auto" : mode === 1 ? "light" : "dark"}
</button>
{children}
</body>
</html>
);
}
The theme is set once in the useEffect and never altered by inner sections.
Tailwind dark: Usage (No Per-Section Overrides)
<section className="py-20 bg-gray-50 dark:bg-zinc-900">
<h2 className="text-3xl font-bold text-gray-800 dark:text-gray-100">
Our Product
</h2>
<p className="mt-4 text-gray-600 dark:text-gray-300">
Seamless experience in any light condition.
</p>
</section>
All colours resolve to the same token set defined by the root theme; switching the root class flips the whole page uniformly.
CSS-Variable Theming (When Using a Component Library)
:root {
--surface: #ffffff;
--text-primary: #1f2937;
--accent: #2563eb;
}
/* Dark mode */
[data-theme="dark"] {
--surface: #111827;
--text-primary: #f3f4f6;
--accent: #60a5fa;
}
<div className="bg-[var(--surface)] text-[var(--text-primary)]">
<button className="bg-[var(--accent)] text-white px-4 py-2">
Get Started
</button>
</div>
The variables are swapped only by the root attribute, guaranteeing a single theme across the page.
Testing Your Theme Lock
Verify the lock holds under real conditions:
- Inspect the page in both colour schemes using browser DevTools → "Toggle device toolbar" → "Force dark mode".
- Confirm that no element flips to the opposite palette; all colour utilities should resolve to the same token set.
- Run an automated accessibility audit to confirm WCAG contrast holds in both modes.
Summary
- The Taste-Skill specification in
skills/taste-skill/SKILL.md§11.1 andCHANGELOG.mddefine the Page Theme Lock as a single theme for the entire page. - Set the theme once in
app/layout.tsx(or equivalent root file) via a client component. - Use either Tailwind v4
dark:variants or CSS custom properties swapped at the root. - Never allow child sections to apply their own
darkclasses or theme tokens. - Always test in both forced modes and run WCAG contrast audits.
Frequently Asked Questions
Can I have a dark hero section and a light content section on the same page?
No. The Taste-Skill specification explicitly prohibits mid-page light/dark flips. Sections do not invert. If the brief requires a "Color Block Story," treat it as a documented exception and enforce the switch exactly once with a clear transition, but do not mix light and dark utility tokens arbitrarily.
Where should I configure the theme in a Next.js app?
Configure it in app/layout.tsx as a client component ("use client"). This root layout is the only place that should touch the <html> element's theme class or data-theme attribute. Child pages and components should remain server-safe and never override the theme.
Does Tailwind require special configuration for class-based dark mode?
Yes. If you use the explicit class strategy shown above, ensure your tailwind.config.js includes darkMode: "class" so that Tailwind generates the dark: variants only when an ancestor has the dark class. Without this, the prefers-color-scheme media query may conflict with your manually set class.
How do I handle the "auto" setting without causing flashes?
Store the user's preference (or default to auto) and apply it in the first useEffect before paint. The example in app/layout.tsx removes explicit classes for auto mode, allowing the prefers-color-scheme media query to take over naturally. Avoid defaulting to a light DOM that later switches to dark.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →