# How to Implement Page Theme Locks to Prevent Light/Dark Flips in Next.js

> Learn how to implement page theme locks in Next.js to prevent light/dark mode flips. Ensure a consistent color scheme across your entire page.

- Repository: [Leon Lin/taste-skill](https://github.com/Leonxlnx/taste-skill)
- Tags: how-to-guide
- Published: 2026-06-05

---

**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`](https://github.com/Leonxlnx/taste-skill/blob/main/skills/taste-skill/SKILL.md) §11.1, "The page has ONE theme. Sections do not invert." This guarantee is also recorded in [`CHANGELOG.md`](https://github.com/Leonxlnx/taste-skill/blob/main/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 `light` class (or omit the `dark` class) to the root element.
- **Explicit Dark** – Add a `dark` class on the root element.

### Set the Theme Once at the Page Root

In a Next.js project, the ideal place is [`app/layout.tsx`](https://github.com/Leonxlnx/taste-skill/blob/main/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 `--surface` and `--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-50` inside a dark page).
- Avoid per-section `<Theme>` or `className="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`](https://github.com/Leonxlnx/taste-skill/blob/main/app/layout.tsx) (Single-Theme Lock)

```tsx
"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)

```html
<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)

```css
:root {
  --surface: #ffffff;
  --text-primary: #1f2937;
  --accent: #2563eb;
}

/* Dark mode */
[data-theme="dark"] {
  --surface: #111827;
  --text-primary: #f3f4f6;
  --accent: #60a5fa;
}

```

```tsx
<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:

1. Inspect the page in both colour schemes using browser DevTools → "Toggle device toolbar" → "Force dark mode".
2. Confirm that no element flips to the opposite palette; all colour utilities should resolve to the same token set.
3. Run an automated accessibility audit to confirm WCAG contrast holds in both modes.

## Summary

- The Taste-Skill specification in [`skills/taste-skill/SKILL.md`](https://github.com/Leonxlnx/taste-skill/blob/main/skills/taste-skill/SKILL.md) §11.1 and [`CHANGELOG.md`](https://github.com/Leonxlnx/taste-skill/blob/main/CHANGELOG.md) define the Page Theme Lock as a single theme for the entire page.
- Set the theme **once** in [`app/layout.tsx`](https://github.com/Leonxlnx/taste-skill/blob/main/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 `dark` classes 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`](https://github.com/Leonxlnx/taste-skill/blob/main/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`](https://github.com/Leonxlnx/taste-skill/blob/main/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`](https://github.com/Leonxlnx/taste-skill/blob/main/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.