Next.js course · Module 3: Styling and Visual Optimization

Dark Mode and User Preferences in Next.js 16

19 min read ·
In this lesson9

In Metropolis Quantum, the city adapts to the needs of its residents. During the day, the glass facades of buildings let in natural sunlight, and holographic displays operate in high-brightness mode. When dusk falls, the city automatically switches to night mode - lighting becomes warmer and less intense, holographic displays switch to dark mode to avoid straining residents' eyes.

In modern web applications, a similar mechanism - Dark Mode - has become not just an aesthetic element but a standard of usability and accessibility. In this chapter, we will learn ways to implement dark mode and manage user preferences in Next.js 16 applications.

Importance of Dark Mode and User Preferences

Why is Dark Mode Important?

  1. Comfort of use - reduces eye fatigue, especially in low-light conditions
  2. Energy savings - on devices with OLED/AMOLED screens, dark mode can significantly extend battery life
  3. Accessibility - helps users with light sensitivity or certain vision impairments
  4. Aesthetics - many people prefer dark interfaces for aesthetic reasons
  5. System compatibility - maintains consistency with the user's operating system preferences

Other User Preferences

In addition to dark mode, it is also worth considering other preferences:

  1. Text size - for improved readability
  2. Contrast - some users need higher contrast
  3. Animations - option to reduce animations for people with vestibular disorders
  4. Preferred language - support for multiple languages
  5. Layout - layout change options (e.g., content density)

Implementing Dark Mode in Next.js 16

Next.js 16 offers several approaches to implementing dark mode:

1. CSS and CSS Variables

The simplest approach uses CSS variables and class selectors:

/* app/globals.css */
:root {
  /* Variables for light mode */
  --background: #ffffff;
  --foreground: #121212;
  --primary: #0070f3;
  --secondary: #6b7280;
  --accent: #8b5cf6;
  --muted: #f3f4f6;
  --border: #e5e7eb;
}

/* Class for dark mode */
.dark-theme {
  --background: #121212;
  --foreground: #f3f4f6;
  --primary: #3b82f6;
  --secondary: #9ca3af;
  --accent: #a78bfa;
  --muted: #1f2937;
  --border: #374151;
}

body {
  background-color: var(--background);
  color: var(--foreground);
  transition: background-color 0.3s ease, color 0.3s ease;
}

.card {
  background-color: var(--muted);
  border: 1px solid var(--border);
  color: var(--foreground);
}

.button-primary {
  background-color: var(--primary);
  color: white;
}

2. Theme Toggle Component

// components/ThemeToggle.tsx
'use client';

import { useState, useEffect } from 'react';
import { MoonIcon, SunIcon } from '@/components/Icons';
import styles from './ThemeToggle.module.css';

export function ThemeToggle() {
  // Start with a neutral value to avoid flash during hydration
  const [isDarkMode, setIsDarkMode] = useState<boolean | undefined>(undefined);

  useEffect(() => {
    // Check system preferences
    const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;

    // Check saved user preferences
    const savedTheme = localStorage.getItem('theme');
    const initialTheme = savedTheme || (prefersDark ? 'dark' : 'light');

    setIsDarkMode(initialTheme === 'dark');
    document.documentElement.classList.toggle('dark-theme', initialTheme === 'dark');
  }, []);

  // Effect that runs when the state changes
  useEffect(() => {
    if (isDarkMode === undefined) return;

    document.documentElement.classList.toggle('dark-theme', isDarkMode);
    localStorage.setItem('theme', isDarkMode ? 'dark' : 'light');
  }, [isDarkMode]);

  // Do not render the button before determining initial state
  if (isDarkMode === undefined) return null;

  return (
    <button
      className={styles.themeToggle}
      onClick={() => setIsDarkMode(!isDarkMode)}
      aria-label={isDarkMode ? 'Switch to light theme' : 'Switch to dark theme'}
    >
      {isDarkMode ? <SunIcon /> : <MoonIcon />}
    </button>
  );
}
/* components/ThemeToggle.module.css */
.themeToggle {
  background: none;
  border: none;
  width: 40px;
  height: 40px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  color: var(--foreground);
  transition: background-color 0.3s ease;
}

.themeToggle:hover {
  background-color: var(--muted);
}

3. Context Provider for Global Theme Access

// contexts/ThemeContext.tsx
'use client';

import { createContext, useState, useEffect, useContext, ReactNode } from 'react';

type Theme = 'light' | 'dark';

interface ThemeContextType {
  theme: Theme;
  setTheme: (theme: Theme) => void;
  toggleTheme: () => void;
}

const ThemeContext = createContext<ThemeContextType | undefined>(undefined);

export function ThemeProvider({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState<Theme>('light');

  // Initialize theme based on system preferences and saved settings
  useEffect(() => {
    // Check if we are in a browser environment
    if (typeof window !== 'undefined') {
      const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
      const savedTheme = localStorage.getItem('theme') as Theme | null;

      const initialTheme = savedTheme || (prefersDark ? 'dark' : 'light');
      setTheme(initialTheme);
      document.documentElement.classList.toggle('dark-theme', initialTheme === 'dark');
    }
  }, []);

  // Effect that runs when the theme changes
  useEffect(() => {
    if (typeof window !== 'undefined') {
      document.documentElement.classList.toggle('dark-theme', theme === 'dark');
      localStorage.setItem('theme', theme);
    }
  }, [theme]);

  // Function to toggle theme
  const toggleTheme = () => {
    setTheme(prevTheme => (prevTheme === 'light' ? 'dark' : 'light'));
  };

  return (
    <ThemeContext.Provider value={{ theme, setTheme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

// Hook to use theme context
export function useTheme() {
  const context = useContext(ThemeContext);
  if (context === undefined) {
    throw new Error('useTheme must be used within a ThemeProvider');
  }
  return context;
}

4. Implementation in the Root Layout

// app/layout.tsx
import { ThemeProvider } from '@/contexts/ThemeContext';
import './globals.css';
import type { Metadata } from 'next';

export const metadata: Metadata = {
  title: 'Metropolis Quantum',
  description: 'The city of the future at your fingertips',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="pl">
      <body>
        <ThemeProvider>
          {children}
        </ThemeProvider>
      </body>
    </html>
  );
}

5. Using Context in Components

// components/Header.tsx
'use client';

import { useTheme } from '@/contexts/ThemeContext';
import Link from 'next/link';
import styles from './Header.module.css';

export function Header() {
  const { theme, toggleTheme } = useTheme();

  return (
    <header className={styles.header}>
      <div className={styles.logo}>
        <Link href="/">Metropolis Quantum</Link>
      </div>
      <nav className={styles.nav}>
        <ul>
          <li><Link href="/about">About us</Link></li>
          <li><Link href="/services">Services</Link></li>
          <li><Link href="/contact">Contact</Link></li>
        </ul>
      </nav>
      <button
        className={styles.themeToggle}
        onClick={toggleTheme}
        aria-label={theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'}
      >
        {theme === 'dark' ? 'Light' : 'Dark'}
      </button>
    </header>
  );
}

Advanced Dark Mode Implementations

1. Using next-themes

next-themes is a popular library that simplifies dark mode implementation:

npm install next-themes
// app/providers.tsx
'use client';

import { ThemeProvider } from 'next-themes';

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
      {children}
    </ThemeProvider>
  );
}
// app/layout.tsx
import { Providers } from './providers';
import './globals.css';
import type { Metadata } from 'next';

export const metadata: Metadata = {
  title: 'Metropolis Quantum',
  description: 'The city of the future at your fingertips',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="pl" suppressHydrationWarning>
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}
/* app/globals.css */
:root {
  --background: #ffffff;
  --foreground: #121212;
  /* Other variables for the light theme */
}

.dark {
  --background: #121212;
  --foreground: #f3f4f6;
  /* Other variables for the dark theme */
}

body {
  background-color: var(--background);
  color: var(--foreground);
  transition: background-color 0.3s ease, color 0.3s ease;
}
// components/ThemeSwitch.tsx
'use client';

import { useTheme } from 'next-themes';
import { useEffect, useState } from 'react';

export function ThemeSwitch() {
  const [mounted, setMounted] = useState(false);
  const { theme, setTheme } = useTheme();

  // Effect that runs after the component mounts
  useEffect(() => {
    setMounted(true);
  }, []);

  if (!mounted) {
    return null; // Do not render during the first render (SSR)
  }

  return (
    <select
      value={theme}
      onChange={(e) => setTheme(e.target.value)}
      className="bg-transparent px-4 py-2 rounded border border-gray-300 dark:border-gray-700"
    >
      <option value="system">System settings</option>
      <option value="light">Light</option>
      <option value="dark">Dark</option>
    </select>
  );
}

2. Support for Multiple Themes (Not Just Light/Dark)

// contexts/ThemeContext.tsx
'use client';

import { createContext, useState, useEffect, useContext, ReactNode } from 'react';

type Theme = 'light' | 'dark' | 'sepia' | 'forest' | 'ocean';

interface ThemeContextType {
  theme: Theme;
  setTheme: (theme: Theme) => void;
}

const ThemeContext = createContext<ThemeContextType | undefined>(undefined);

export function ThemeProvider({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState<Theme>('light');

  // Theme initialization
  useEffect(() => {
    if (typeof window !== 'undefined') {
      const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
      const savedTheme = localStorage.getItem('theme') as Theme | null;

      const initialTheme = savedTheme || (prefersDark ? 'dark' : 'light');
      setTheme(initialTheme);

      // Remove all theme classes
      document.documentElement.classList.remove('theme-light', 'theme-dark', 'theme-sepia', 'theme-forest', 'theme-ocean');
      // Add current theme class
      document.documentElement.classList.add(`theme-${initialTheme}`);
    }
  }, []);

  // Effect that runs when the theme changes
  useEffect(() => {
    if (typeof window !== 'undefined') {
      // Remove all theme classes
      document.documentElement.classList.remove('theme-light', 'theme-dark', 'theme-sepia', 'theme-forest', 'theme-ocean');
      // Add current theme class
      document.documentElement.classList.add(`theme-${theme}`);
      localStorage.setItem('theme', theme);
    }
  }, [theme]);

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useTheme() {
  const context = useContext(ThemeContext);
  if (context === undefined) {
    throw new Error('useTheme must be used within a ThemeProvider');
  }
  return context;
}
/* app/globals.css */
:root {
  /* Base variables */
}

/* Light theme (default) */
.theme-light {
  --background: #ffffff;
  --foreground: #121212;
  --primary: #0070f3;
  --secondary: #6b7280;
  --accent: #8b5cf6;
  --muted: #f3f4f6;
  --border: #e5e7eb;
}

/* Dark theme */
.theme-dark {
  --background: #121212;
  --foreground: #f3f4f6;
  --primary: #3b82f6;
  --secondary: #9ca3af;
  --accent: #a78bfa;
  --muted: #1f2937;
  --border: #374151;
}

/* Sepia theme (easy on the eyes) */
.theme-sepia {
  --background: #f9f5eb;
  --foreground: #433422;
  --primary: #9c6644;
  --secondary: #7d6e5d;
  --accent: #c27b57;
  --muted: #efe8d9;
  --border: #d3c6ad;
}

/* Forest theme */
.theme-forest {
  --background: #1e2a20;
  --foreground: #e9f0db;
  --primary: #52b788;
  --secondary: #74c69d;
  --accent: #95d5b2;
  --muted: #2d3a2e;
  --border: #40534e;
}

/* Ocean theme */
.theme-ocean {
  --background: #0c2333;
  --foreground: #e0f2fe;
  --primary: #0ea5e9;
  --secondary: #38bdf8;
  --accent: #7dd3fc;
  --muted: #164e63;
  --border: #0369a1;
}

body {
  background-color: var(--background);
  color: var(--foreground);
  transition: background-color 0.3s ease, color 0.3s ease;
}

3. Animated Transitions Between Themes

/* app/globals.css */
body {
  background-color: var(--background);
  color: var(--foreground);
  transition:
    background-color 0.5s ease,
    color 0.5s ease,
    border-color 0.5s ease,
    box-shadow 0.5s ease;
}

/* Animation for nested elements */
* {
  transition:
    background-color 0.5s ease,
    color 0.5s ease,
    border-color 0.5s ease,
    fill 0.5s ease,
    stroke 0.5s ease;
}

4. Applying Tailwind CSS for Dark Mode

Tailwind CSS has built-in support for dark mode:

// components/Card.tsx
export function Card({ title, content }) {
  return (
    <div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md dark:shadow-gray-900 border border-gray-200 dark:border-gray-700">
      <h3 className="text-xl font-bold text-gray-900 dark:text-white mb-2">{title}</h3>
      <p className="text-gray-700 dark:text-gray-300">{content}</p>
    </div>
  );
}

Saving and Managing User Preferences

1. Using localStorage

// utils/userPreferences.ts
export interface UserPreferences {
  theme: 'light' | 'dark' | 'system';
  fontSize: 'small' | 'medium' | 'large';
  reducedMotion: boolean;
  highContrast: boolean;
  language: string;
}

const defaultPreferences: UserPreferences = {
  theme: 'system',
  fontSize: 'medium',
  reducedMotion: false,
  highContrast: false,
  language: 'pl',
};

export function getUserPreferences(): UserPreferences {
  if (typeof window === 'undefined') {
    return defaultPreferences;
  }

  try {
    const savedPreferences = localStorage.getItem('userPreferences');
    return savedPreferences
      ? { ...defaultPreferences, ...JSON.parse(savedPreferences) }
      : defaultPreferences;
  } catch (error) {
    console.error('Error reading user preferences:', error);
    return defaultPreferences;
  }
}

export function saveUserPreferences(preferences: Partial<UserPreferences>): void {
  if (typeof window === 'undefined') {
    return;
  }

  try {
    const currentPreferences = getUserPreferences();
    const updatedPreferences = { ...currentPreferences, ...preferences };
    localStorage.setItem('userPreferences', JSON.stringify(updatedPreferences));
  } catch (error) {
    console.error('Error saving user preferences:', error);
  }
}

2. User Preferences with Cookies

Cookies can be used to store preferences between sessions and are available on both client and server:

// lib/cookies.ts
import { parseCookies, setCookie } from 'nookies';
import { UserPreferences, defaultPreferences } from './userPreferences';

export function getUserPreferencesFromCookies(ctx?: any): UserPreferences {
  const cookies = parseCookies(ctx);

  if (!cookies.userPreferences) {
    return defaultPreferences;
  }

  try {
    return { ...defaultPreferences, ...JSON.parse(cookies.userPreferences) };
  } catch (error) {
    console.error('Error parsing user preferences from cookies:', error);
    return defaultPreferences;
  }
}

export function saveUserPreferencesToCookies(
  preferences: Partial<UserPreferences>,
  ctx?: any
): void {
  const currentPreferences = getUserPreferencesFromCookies(ctx);
  const updatedPreferences = { ...currentPreferences, ...preferences };

  setCookie(ctx, 'userPreferences', JSON.stringify(updatedPreferences), {
    maxAge: 365 * 24 * 60 * 60, // 1 year
    path: '/',
    sameSite: 'strict',
    secure: process.env.NODE_ENV === 'production',
  });
}

3. Switches for Different Preferences

// components/UserPreferencesPanel.tsx
'use client';

import { useState, useEffect } from 'react';
import { UserPreferences, getUserPreferences, saveUserPreferences } from '@/utils/userPreferences';
import styles from './UserPreferencesPanel.module.css';

export function UserPreferencesPanel() {
  const [preferences, setPreferences] = useState<UserPreferences | null>(null);
  const [isOpen, setIsOpen] = useState(false);

  useEffect(() => {
    setPreferences(getUserPreferences());
  }, []);

  if (!preferences) return null;

  const updatePreference = <K extends keyof UserPreferences>(
    key: K,
    value: UserPreferences[K]
  ) => {
    const updatedPreferences = { ...preferences, [key]: value };
    setPreferences(updatedPreferences);
    saveUserPreferences({ [key]: value });

    // Apply preferences immediately
    applyPreferences(key, value);
  };

  const applyPreferences = <K extends keyof UserPreferences>(
    key: K,
    value: UserPreferences[K]
  ) => {
    switch (key) {
      case 'theme':
        const theme = value as 'light' | 'dark' | 'system';
        const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
        const effectiveTheme = theme === 'system' ? (prefersDark ? 'dark' : 'light') : theme;

        document.documentElement.classList.remove('theme-light', 'theme-dark');
        document.documentElement.classList.add(`theme-${effectiveTheme}`);
        break;

      case 'fontSize':
        document.documentElement.style.fontSize = {
          small: '14px',
          medium: '16px',
          large: '18px',
        }[value as string] || '16px';
        break;

      case 'reducedMotion':
        document.documentElement.classList.toggle('reduce-motion', value as boolean);
        break;

      case 'highContrast':
        document.documentElement.classList.toggle('high-contrast', value as boolean);
        break;

      case 'language':
        // In a real application - here you would implement language change
        break;
    }
  };

  return (
    <>
      <button
        className={styles.settingsButton}
        onClick={() => setIsOpen(!isOpen)}
        aria-expanded={isOpen}
      >
        Preferences
      </button>

      {isOpen && (
        <div className={styles.panel}>
          <h2>User Preferences</h2>

          <div className={styles.preferenceGroup}>
            <h3>Theme</h3>
            <div className={styles.options}>
              <label>
                <input
                  type="radio"
                  name="theme"
                  value="light"
                  checked={preferences.theme === 'light'}
                  onChange={() => updatePreference('theme', 'light')}
                />
                Light
              </label>

              <label>
                <input
                  type="radio"
                  name="theme"
                  value="dark"
                  checked={preferences.theme === 'dark'}
                  onChange={() => updatePreference('theme', 'dark')}
                />
                Dark
              </label>

              <label>
                <input
                  type="radio"
                  name="theme"
                  value="system"
                  checked={preferences.theme === 'system'}
                  onChange={() => updatePreference('theme', 'system')}
                />
                Match system
              </label>
            </div>
          </div>

          <div className={styles.preferenceGroup}>
            <h3>Text size</h3>
            <div className={styles.options}>
              <label>
                <input
                  type="radio"
                  name="fontSize"
                  value="small"
                  checked={preferences.fontSize === 'small'}
                  onChange={() => updatePreference('fontSize', 'small')}
                />
                Small
              </label>

              <label>
                <input
                  type="radio"
                  name="fontSize"
                  value="medium"
                  checked={preferences.fontSize === 'medium'}
                  onChange={() => updatePreference('fontSize', 'medium')}
                />
                Medium
              </label>

              <label>
                <input
                  type="radio"
                  name="fontSize"
                  value="large"
                  checked={preferences.fontSize === 'large'}
                  onChange={() => updatePreference('fontSize', 'large')}
                />
                Large
              </label>
            </div>
          </div>

          <div className={styles.preferenceGroup}>
            <h3>Accessibility</h3>
            <div className={styles.options}>
              <label>
                <input
                  type="checkbox"
                  checked={preferences.reducedMotion}
                  onChange={(e) => updatePreference('reducedMotion', e.target.checked)}
                />
                Reduced motion
              </label>

              <label>
                <input
                  type="checkbox"
                  checked={preferences.highContrast}
                  onChange={(e) => updatePreference('highContrast', e.target.checked)}
                />
                High contrast
              </label>
            </div>
          </div>

          <div className={styles.preferenceGroup}>
            <h3>Language</h3>
            <select
              value={preferences.language}
              onChange={(e) => updatePreference('language', e.target.value)}
              className={styles.select}
            >
              <option value="pl">Polski</option>
              <option value="en">English</option>
              <option value="de">Deutsch</option>
              <option value="fr">Français</option>
            </select>
          </div>

          <button
            className={styles.closeButton}
            onClick={() => setIsOpen(false)}
          >
            Close
          </button>
        </div>
      )}
    </>
  );
}

CSS for Different Preferences

1. CSS Variables for Different Preferences

/* app/globals.css */
:root {
  /* Base variables */
  --background: #ffffff;
  --foreground: #121212;
  /* ... (other base colors) ... */

  /* Variables for text size */
  --font-size-base: 16px;
  --font-size-small: 0.875rem;
  --font-size-medium: 1rem;
  --font-size-large: 1.25rem;
  --font-size-xl: 1.5rem;
  --font-size-2xl: 2rem;

  /* Variables for spacing */
  --spacing-xs: 0.25rem;
  --spacing-sm: 0.5rem;
  --spacing-md: 1rem;
  --spacing-lg: 1.5rem;
  --spacing-xl: 2rem;

  /* Variables for animation */
  --transition-speed: 0.3s;
}

/* Dark mode */
.theme-dark {
  --background: #121212;
  --foreground: #f3f4f6;
  /* ... (other dark mode colors) ... */
}

/* High contrast */
.high-contrast {
  --background: #000000;
  --foreground: #ffffff;
  --primary: #ffff00;
  --secondary: #00ffff;
  --accent: #ff00ff;
  --muted: #333333;
  --border: #ffffff;
}

/* Large text */
html[data-text-size="large"] {
  --font-size-base: 18px;
  --font-size-small: 1rem;
  --font-size-medium: 1.125rem;
  --font-size-large: 1.375rem;
  --font-size-xl: 1.75rem;
  --font-size-2xl: 2.25rem;
}

/* Reduced motion */
.reduce-motion * {
  animation-duration: 0.001s !important;
  transition-duration: 0.001s !important;
  scroll-behavior: auto !important;
}

/* Base styles */
html {
  font-size: var(--font-size-base);
}

body {
  background-color: var(--background);
  color: var(--foreground);
  font-size: var(--font-size-medium);
  transition:
    background-color var(--transition-speed) ease,
    color var(--transition-speed) ease;
}

a {
  color: var(--primary);
  transition: color var(--transition-speed) ease;
}

.reduce-motion a {
  transition: none;
}

2. Component Heights for Different Text Sizes

/* components/Button.module.css */
.button {
  background-color: var(--primary);
  color: white;
  border-radius: 0.375rem;
  font-weight: 500;
  padding: var(--spacing-sm) var(--spacing-md);
  font-size: var(--font-size-medium);
  height: calc(var(--font-size-medium) * 2.5);
  line-height: 1;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  transition: background-color var(--transition-speed) ease;
}

.button:hover {
  background-color: var(--primary-dark);
}

.reduce-motion .button {
  transition: none;
}

Implementation for Advanced Cases

1. Dark Mode with Server-side Storage

For applications with authentication, user preferences can be stored on the server:

// app/api/preferences/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth/next';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { prisma } from '@/lib/prisma';

export async function GET(req: NextRequest) {
  const session = await getServerSession(authOptions);

  if (!session || !session.user) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  try {
    const preferences = await prisma.userPreferences.findUnique({
      where: { userId: session.user.id },
    });

    return NextResponse.json(preferences);
  } catch (error) {
    return NextResponse.json({ error: 'Failed to fetch preferences' }, { status: 500 });
  }
}

export async function POST(req: NextRequest) {
  const session = await getServerSession(authOptions);

  if (!session || !session.user) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  try {
    const data = await req.json();
    const preferences = await prisma.userPreferences.upsert({
      where: { userId: session.user.id },
      update: data,
      create: {
        userId: session.user.id,
        ...data,
      },
    });

    return NextResponse.json(preferences);
  } catch (error) {
    return NextResponse.json({ error: 'Failed to update preferences' }, { status: 500 });
  }
}

2. SSR and Pre-rendering with Preference Support

// proxy.ts (up to Next.js 15: middleware.ts with a middleware function)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function proxy(request: NextRequest) {
  const response = NextResponse.next();

  // If this is the first request, check if a preference cookie exists
  const hasPrefsCookie = request.cookies.has('userPreferences');

  // If there is no cookie and this is the first HTML rendering,
  // add a script detecting system preferences
  if (!hasPrefsCookie && request.headers.get('sec-fetch-dest') === 'document') {
    const url = request.nextUrl.clone();

    // If this is an HTML page request and not API or static assets
    if (!url.pathname.startsWith('/api/') && !url.pathname.match(/\.(js|css|png|jpg|svg)$/)) {
      const response = NextResponse.next();

      // In a real implementation you can add a script detecting preferences
      // and setting appropriate cookies before the first rendering

      return response;
    }
  }

  return response;
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};

3. Automatic Dark Mode Switching Based on Time of Day

// hooks/useDayNightMode.ts
import { useState, useEffect } from 'react';

export function useDayNightMode() {
  const [isDarkMode, setIsDarkMode] = useState(false);

  useEffect(() => {
    // Function checking if it is currently night (from 18:00 to 6:00)
    const checkIfNight = () => {
      const hours = new Date().getHours();
      return hours >= 18 || hours < 6;
    };

    // Initial setting
    setIsDarkMode(checkIfNight());

    // Interval checking time every minute
    const interval = setInterval(() => {
      setIsDarkMode(checkIfNight());
    }, 60000);

    return () => clearInterval(interval);
  }, []);

  return isDarkMode;
}
// components/AutomaticThemeToggle.tsx
'use client';

import { useState, useEffect } from 'react';
import { useDayNightMode } from '@/hooks/useDayNightMode';
import styles from './AutomaticThemeToggle.module.css';

export function AutomaticThemeToggle() {
  const [isAutoMode, setIsAutoMode] = useState(false);
  const isDarkMode = useDayNightMode();
  const [manualDarkMode, setManualDarkMode] = useState(false);

  // Effect changing mode based on automatic detection or manual setting
  useEffect(() => {
    if (isAutoMode) {
      document.documentElement.classList.toggle('dark-theme', isDarkMode);
    } else {
      document.documentElement.classList.toggle('dark-theme', manualDarkMode);
    }
  }, [isAutoMode, isDarkMode, manualDarkMode]);

  return (
    <div className={styles.themeControls}>
      <div className={styles.autoToggle}>
        <label>
          <input
            type="checkbox"
            checked={isAutoMode}
            onChange={(e) => setIsAutoMode(e.target.checked)}
          />
          Automatic day/night mode
        </label>
      </div>

      {!isAutoMode && (
        <div className={styles.manualToggle}>
          <button
            onClick={() => setManualDarkMode(!manualDarkMode)}
            className={styles.themeButton}
          >
            {manualDarkMode ? 'Switch to light mode' : 'Switch to dark mode'}
          </button>
        </div>
      )}

      <div className={styles.currentMode}>
        Current mode: {(isAutoMode ? isDarkMode : manualDarkMode) ? 'Dark' : 'Light'}
      </div>
    </div>
  );
}

Best Practices

  1. Respect system preferences - always check prefers-color-scheme as the default setting
  2. Avoid flashes - prevent content flickering when switching modes
  3. Consistent colors - make sure all interface elements properly respond to mode changes
  4. Test thoroughly - check how the application looks in both modes
  5. Accessibility - remember that dark mode is not just about appearance but also accessibility
  6. Performance - avoid multiple class and style rewrites when switching
  7. Signal active mode - clearly indicate which mode is currently active
  8. Save preferences - save selected user preferences between sessions

Implementation with TailwindCSS

Tailwind CSS integrates excellently with dark mode:

// components/DarkModeExample.tsx
'use client';

import { useState, useEffect } from 'react';

export function DarkModeExample() {
  const [darkMode, setDarkMode] = useState(false);

  useEffect(() => {
    // Check system preferences
    const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
    const savedTheme = localStorage.getItem('theme');
    const initialDarkMode = savedTheme === 'dark' || (!savedTheme && prefersDark);

    setDarkMode(initialDarkMode);
    document.documentElement.classList.toggle('dark', initialDarkMode);
  }, []);

  const toggleDarkMode = () => {
    const newDarkMode = !darkMode;
    setDarkMode(newDarkMode);
    document.documentElement.classList.toggle('dark', newDarkMode);
    localStorage.setItem('theme', newDarkMode ? 'dark' : 'light');
  };

  return (
    <div className="p-8 bg-white dark:bg-gray-900 min-h-screen transition-colors duration-300">
      <div className="max-w-4xl mx-auto">
        <div className="flex justify-between items-center mb-8">
          <h1 className="text-3xl font-bold text-gray-900 dark:text-white">
            Metropolis Quantum
          </h1>

          <button
            onClick={toggleDarkMode}
            className="p-2 rounded-full bg-gray-200 dark:bg-gray-800 text-gray-800 dark:text-white transition-colors duration-300"
          >
            {darkMode ? 'Light' : 'Dark'}
          </button>
        </div>

        <div className="grid gap-6 md:grid-cols-2">
          <div className="bg-gray-100 dark:bg-gray-800 p-6 rounded-lg shadow-md dark:shadow-gray-800/30">
            <h2 className="text-xl font-semibold text-gray-800 dark:text-white mb-4">
              About the City
            </h2>
            <p className="text-gray-700 dark:text-gray-300">
              Metropolis Quantum is the city of the future where technology and humanity coexist in harmony.
            </p>
          </div>

          <div className="bg-gray-100 dark:bg-gray-800 p-6 rounded-lg shadow-md dark:shadow-gray-800/30">
            <h2 className="text-xl font-semibold text-gray-800 dark:text-white mb-4">
              Services
            </h2>
            <ul className="space-y-2 text-gray-700 dark:text-gray-300">
              <li className="flex items-center">
                <svg className="w-5 h-5 mr-2 text-blue-600 dark:text-blue-400" fill="currentColor" viewBox="0 0 20 20">
                  <path d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" />
                </svg>
                Quantum transport
              </li>
              <li className="flex items-center">
                <svg className="w-5 h-5 mr-2 text-blue-600 dark:text-blue-400" fill="currentColor" viewBox="0 0 20 20">
                  <path d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" />
                </svg>
                Holographic Displays
              </li>
              <li className="flex items-center">
                <svg className="w-5 h-5 mr-2 text-blue-600 dark:text-blue-400" fill="currentColor" viewBox="0 0 20 20">
                  <path d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" />
                </svg>
                Intelligent infrastructure
              </li>
            </ul>
          </div>
        </div>

        <div className="mt-8 bg-blue-600 dark:bg-blue-800 p-6 rounded-lg text-white shadow-lg">
          <h2 className="text-xl font-semibold mb-4">Join the Community</h2>
          <p className="mb-4">
            Discover the amazing possibilities that Metropolis Quantum offers.
          </p>
          <button className="px-4 py-2 bg-white text-blue-600 dark:bg-gray-800 dark:text-blue-400 rounded-md font-medium hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-300">
            Sign Up
          </button>
        </div>
      </div>
    </div>
  );
}

Summary

Effective implementation of dark mode and managing user preferences in Next.js 16 is an important element of user experience. Just as Quantum Metropolis adapts its systems to the time of day and residents' preferences, modern web applications should respect user choices while also caring about accessibility, performance, and interface consistency.

Key takeaways:

  1. Respect system preferences - many users already have their preferred mode set in the system
  2. Provide a simple toggle - enable easy switching between modes
  3. Save user choices - remember preferences between sessions
  4. Design for both modes - consider the specifics of both modes at the design stage
  5. Pay attention to accessibility - dark mode is not just about aesthetics but also accessibility
  6. Integrate with other preferences - allow users to customize other aspects of the interface as well

Thanks to modern tools such as CSS variables, React's context API, and libraries like next-themes, implementing dark mode and managing user preferences becomes simpler and more effective. Next.js 16 provides a solid foundation for creating adaptive interfaces that respond to user preferences and deliver optimal experiences regardless of conditions.

Code for this lesson: app/page.tsx
// Accessibility & WCAG - Quantum Metropolis
'use client';

export default function AccessibilityDemo() {
  return (
    <div style={{
      minHeight: '100vh',
      background: 'linear-gradient(135deg, #0f0f23, #1a1a2e)',
      color: '#ffffff',
      padding: '3rem 2rem'
    }}>
      <a href="#main-content" style={{
        position: 'absolute',
        left: '-9999px',
        zIndex: 999,
        padding: '1rem',
        background: '#64ffda',
        color: '#0f0f23',
        textDecoration: 'none',
        borderRadius: '0.5rem',
        fontWeight: 600
      }}>
        Skip to main content
      </a>

      <header role="banner" style={{maxWidth: '1200px', margin: '0 auto 3rem'}}>
        <h1 style={{
          fontSize: '3rem',
          textAlign: 'center',
          background: 'linear-gradient(45deg, #64ffda, #7c4dff)',
          WebkitBackgroundClip: 'text',
          WebkitTextFillColor: 'transparent',
          marginBottom: '1rem'
        }}>
          Accessibility & WCAG
        </h1>
        <p style={{textAlign: 'center', color: '#b0bec5', fontSize: '1.25rem'}}>
          Building accessible interfaces for everyone
        </p>
      </header>

      <main id="main-content" role="main" style={{maxWidth: '1200px', margin: '0 auto'}}>
        {/* Semantic HTML */}
        <section aria-labelledby="semantic-heading" style={{marginBottom: '3rem', background: 'rgba(255,255,255,0.05)', padding: '2rem', borderRadius: '1rem', border: '1px solid rgba(100,255,218,0.2)'}}>
          <h2 id="semantic-heading" style={{color: '#64ffda', marginBottom: '1.5rem'}}>1. Semantic HTML</h2>
          <div style={{color: '#b0bec5', lineHeight: 1.8}}>
            <p style={{marginBottom: '1rem'}}>Use semantic HTML tags instead of generic &lt;div&gt;:</p>
            <ul style={{paddingLeft: '2rem', marginBottom: '1.5rem'}}>
              <li>&lt;header&gt;, &lt;nav&gt;, &lt;main&gt;, &lt;article&gt;, &lt;section&gt;, &lt;aside&gt;, &lt;footer&gt;</li>
              <li>&lt;button&gt; instead of &lt;div onclick&gt;</li>
              <li>&lt;h1&gt; to &lt;h6&gt; for heading hierarchy</li>
            </ul>
            <pre style={{background: 'rgba(0,0,0,0.5)', padding: '1rem', borderRadius: '0.5rem', overflow: 'auto', fontSize: '0.875rem'}}>
{`<article aria-labelledby="article-title">
  <h2 id="article-title">Article title</h2>
  <p>Article content...</p>
</article>`}
            </pre>
          </div>
        </section>

        {/* ARIA Attributes */}
        <section aria-labelledby="aria-heading" style={{marginBottom: '3rem', background: 'rgba(255,255,255,0.05)', padding: '2rem', borderRadius: '1rem', border: '1px solid rgba(100,255,218,0.2)'}}>
          <h2 id="aria-heading" style={{color: '#64ffda', marginBottom: '1.5rem'}}>2. ARIA Attributes</h2>

          <div style={{marginBottom: '2rem'}}>
            <h3 style={{color: '#7c4dff', marginBottom: '1rem'}}>Example: Button</h3>
            <button
              aria-label="Close dialog window"
              style={{
                padding: '0.75rem 2rem',
                background: 'linear-gradient(135deg, #64ffda, #448aff)',
                border: 'none',
                borderRadius: '0.75rem',
                color: '#0f0f23',
                fontWeight: 600,
                cursor: 'pointer',
                transition: 'all 0.3s ease'
              }}
              onFocus={e => e.currentTarget.style.outline = '3px solid #64ffda'}
              onBlur={e => e.currentTarget.style.outline = 'none'}
            >
              Close
            </button>
            <pre style={{background: 'rgba(0,0,0,0.5)', padding: '1rem', borderRadius: '0.5rem', overflow: 'auto', fontSize: '0.875rem', marginTop: '1rem'}}>
{`<button aria-label="Close dialog window">
  Close
</button>`}
            </pre>
          </div>

          <div>
            <h3 style={{color: '#7c4dff', marginBottom: '1rem'}}>Example: Progress Bar</h3>
            <div
              role="progressbar"
              aria-valuenow={75}
              aria-valuemin={0}
              aria-valuemax={100}
              aria-label="Loading progress"
              style={{width: '100%', height: '12px', background: 'rgba(255,255,255,0.1)', borderRadius: '6px', overflow: 'hidden'}}
            >
              <div style={{width: '75%', height: '100%', background: 'linear-gradient(90deg, #64ffda, #7c4dff)', borderRadius: '6px', transition: 'width 0.5s ease'}}></div>
            </div>
            <pre style={{background: 'rgba(0,0,0,0.5)', padding: '1rem', borderRadius: '0.5rem', overflow: 'auto', fontSize: '0.875rem', marginTop: '1rem'}}>
{`<div
  role="progressbar"
  aria-valuenow="75"
  aria-valuemin="0"
  aria-valuemax="100"
  aria-label="Loading progress"
>
  <div style="width: 75%"></div>
</div>`}
            </pre>
          </div>
        </section>

        {/* Focus Management */}
        <section aria-labelledby="focus-heading" style={{marginBottom: '3rem', background: 'rgba(255,255,255,0.05)', padding: '2rem', borderRadius: '1rem', border: '1px solid rgba(100,255,218,0.2)'}}>
          <h2 id="focus-heading" style={{color: '#64ffda', marginBottom: '1.5rem'}}>3. Focus Styles</h2>
          <p style={{color: '#b0bec5', marginBottom: '1.5rem', lineHeight: 1.6}}>
            Always provide visible focus states for keyboard-navigating users
          </p>

          <div style={{display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: '1rem'}}>
            <button style={{
              padding: '1rem',
              background: 'rgba(100,255,218,0.2)',
              border: '2px solid #64ffda',
              borderRadius: '0.75rem',
              color: '#64ffda',
              fontWeight: 600,
              cursor: 'pointer',
              transition: 'all 0.3s ease'
            }}
            onFocus={e => {
              e.currentTarget.style.outline = '3px solid #64ffda';
              e.currentTarget.style.outlineOffset = '2px';
            }}
            onBlur={e => e.currentTarget.style.outline = 'none'}
            >
              Button 1
            </button>

            <button style={{
              padding: '1rem',
              background: 'rgba(124,77,255,0.2)',
              border: '2px solid #7c4dff',
              borderRadius: '0.75rem',
              color: '#7c4dff',
              fontWeight: 600,
              cursor: 'pointer',
              transition: 'all 0.3s ease'
            }}
            onFocus={e => {
              e.currentTarget.style.outline = '3px solid #7c4dff';
              e.currentTarget.style.outlineOffset = '2px';
            }}
            onBlur={e => e.currentTarget.style.outline = 'none'}
            >
              Button 2
            </button>

            <button style={{
              padding: '1rem',
              background: 'rgba(255,0,128,0.2)',
              border: '2px solid #ff0080',
              borderRadius: '0.75rem',
              color: '#ff0080',
              fontWeight: 600,
              cursor: 'pointer',
              transition: 'all 0.3s ease'
            }}
            onFocus={e => {
              e.currentTarget.style.outline = '3px solid #ff0080';
              e.currentTarget.style.outlineOffset = '2px';
            }}
            onBlur={e => e.currentTarget.style.outline = 'none'}
            >
              Button 3
            </button>
          </div>

          <pre style={{background: 'rgba(0,0,0,0.5)', padding: '1rem', borderRadius: '0.5rem', overflow: 'auto', fontSize: '0.875rem', marginTop: '1.5rem'}}>
{`button:focus {
  outline: 3px solid var(--color-primary);
  outline-offset: 2px;
}`}
          </pre>
        </section>

        {/* Color Contrast */}
        <section aria-labelledby="contrast-heading" style={{marginBottom: '3rem', background: 'rgba(255,255,255,0.05)', padding: '2rem', borderRadius: '1rem', border: '1px solid rgba(100,255,218,0.2)'}}>
          <h2 id="contrast-heading" style={{color: '#64ffda', marginBottom: '1.5rem'}}>4. Color Contrast (WCAG AA)</h2>
          <p style={{color: '#b0bec5', marginBottom: '1.5rem', lineHeight: 1.6}}>
            Minimum contrast ratios according to WCAG:
          </p>

          <ul style={{color: '#b0bec5', lineHeight: 2, paddingLeft: '2rem', marginBottom: '1.5rem'}}>
            <li>Normal text: 4.5:1</li>
            <li>Large text (18pt+ or 14pt+ bold): 3:1</li>
            <li>UI components & graphics: 3:1</li>
          </ul>

          <div style={{display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '1.5rem'}}>
            <div style={{background: '#ffffff', padding: '1.5rem', borderRadius: '0.75rem'}}>
              <p style={{color: '#212529', fontWeight: 600, marginBottom: '0.5rem'}}>Good Contrast</p>
              <p style={{color: '#212529', fontSize: '0.875rem'}}>Black text on white background (21:1)</p>
            </div>

            <div style={{background: '#64ffda', padding: '1.5rem', borderRadius: '0.75rem'}}>
              <p style={{color: '#0f0f23', fontWeight: 600, marginBottom: '0.5rem'}}>Good Contrast</p>
              <p style={{color: '#0f0f23', fontSize: '0.875rem'}}>Dark text on cyan background (9.2:1)</p>
            </div>
          </div>
        </section>

        {/* Screen Reader Support */}
        <section aria-labelledby="sr-heading" style={{marginBottom: '3rem', background: 'rgba(255,255,255,0.05)', padding: '2rem', borderRadius: '1rem', border: '1px solid rgba(100,255,218,0.2)'}}>
          <h2 id="sr-heading" style={{color: '#64ffda', marginBottom: '1.5rem'}}>5. Screen Reader Support</h2>

          <div style={{marginBottom: '2rem'}}>
            <h3 style={{color: '#7c4dff', marginBottom: '1rem'}}>Visually hidden but accessible to SR</h3>
            <pre style={{background: 'rgba(0,0,0,0.5)', padding: '1rem', borderRadius: '0.5rem', overflow: 'auto', fontSize: '0.875rem'}}>
{`.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border-width: 0;
}`}
            </pre>
          </div>

          <div>
            <h3 style={{color: '#7c4dff', marginBottom: '1rem'}}>Live Regions</h3>
            <pre style={{background: 'rgba(0,0,0,0.5)', padding: '1rem', borderRadius: '0.5rem', overflow: 'auto', fontSize: '0.875rem'}}>
{`<div
  aria-live="polite"
  aria-atomic="true"
  role="status"
>
  Data has been saved
</div>`}
            </pre>
          </div>
        </section>

        {/* Best Practices Checklist */}
        <section aria-labelledby="checklist-heading" style={{marginBottom: '3rem', background: 'rgba(255,255,255,0.05)', padding: '2rem', borderRadius: '1rem', border: '1px solid rgba(100,255,218,0.2)'}}>
          <h2 id="checklist-heading" style={{color: '#64ffda', marginBottom: '1.5rem'}}>Accessibility Checklist</h2>
          <ul style={{color: '#b0bec5', lineHeight: 2.2, paddingLeft: '2rem', listStyle: 'none'}}>
            <li>Use semantic HTML</li>
            <li>Provide appropriate ARIA labels</li>
            <li>All interactive elements are keyboard accessible</li>
            <li>Focus states are clearly visible</li>
            <li>Color contrast meets WCAG AA (4.5:1)</li>
            <li>Images have alt text</li>
            <li>Forms have labels and error messages</li>
            <li>Application works without JavaScript</li>
            <li>Tested with screen readers</li>
            <li>Responsive and works on various screen sizes</li>
          </ul>
        </section>
      </main>

      <footer role="contentinfo" style={{maxWidth: '1200px', margin: '3rem auto 0', textAlign: 'center', padding: '2rem', borderTop: '1px solid rgba(100,255,218,0.2)', color: '#78909c'}}>
        <p>© 2150 Quantum Metropolis • Accessibility First</p>
      </footer>
    </div>
  );
}

Docs entries used in this lesson

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. Which expression did the lesson use to detect the system dark-theme preference?

  2. 2. Where did the lesson store the user's chosen theme so it survives a page refresh?

Hands-on tasks in the game

  • Vertical ordering

    Arrange the Dark Mode implementation steps described in the lesson in the correct order:

Useful articles