CodeWorlds

Dark Mode and User Preferences in Next.js 16

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

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)

Implementacja Dark Mode w Next.js 16

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

1. CSS i zmienne CSS

The simplest approach uses CSS variables and class selectors:

1/* app/globals.css */
2:root {
3  /* Zmienne dla trybu jasnego */
4  --background: #ffffff;
5  --foreground: #121212;
6  --primary: #0070f3;
7  --secondary: #6b7280;
8  --accent: #8b5cf6;
9  --muted: #f3f4f6;
10  --border: #e5e7eb;
11}
12
13/* Klasa dla trybu ciemnego */
14.dark-theme {
15  --background: #121212;
16  --foreground: #f3f4f6;
17  --primary: #3b82f6;
18  --secondary: #9ca3af;
19  --accent: #a78bfa;
20  --muted: #1f2937;
21  --border: #374151;
22}
23
24body {
25  background-color: var(--background);
26  color: var(--foreground);
27  transition: background-color 0.3s ease, color 0.3s ease;
28}
29
30.card {
31  background-color: var(--muted);
32  border: 1px solid var(--border);
33  color: var(--foreground);
34}
35
36.button-primary {
37  background-color: var(--primary);
38  color: white;
39}

2. Theme Toggle Component

1// components/ThemeToggle.tsx
2'use client';
3
4import { useState, useEffect } from 'react';
5import { MoonIcon, SunIcon } from '@/components/Icons';
6import styles from './ThemeToggle.module.css';
7
8export function ThemeToggle() {
9  // Start with a neutral value to avoid flash during hydration
10  const [isDarkMode, setIsDarkMode] = useState<boolean | undefined>(undefined);
11
12  useEffect(() => {
13    // Check system preferences
14    const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
15
16    // Check saved user preferences
17    const savedTheme = localStorage.getItem('theme');
18    const initialTheme = savedTheme || (prefersDark ? 'dark' : 'light');
19
20    setIsDarkMode(initialTheme === 'dark');
21    document.documentElement.classList.toggle('dark-theme', initialTheme === 'dark');
22  }, []);
23
24  // Efekt uruchamiany przy zmianie stanu
25  useEffect(() => {
26    if (isDarkMode === undefined) return;
27
28    document.documentElement.classList.toggle('dark-theme', isDarkMode);
29    localStorage.setItem('theme', isDarkMode ? 'dark' : 'light');
30  }, [isDarkMode]);
31
32  // Do not render the button before determining initial state
33  if (isDarkMode === undefined) return null;
34
35  return (
36    <button
37      className={styles.themeToggle}
38      onClick={() => setIsDarkMode(!isDarkMode)}
39      aria-label={isDarkMode ? 'Switch to light theme' : 'Switch to dark theme'}
40    >
41      {isDarkMode ? <SunIcon /> : <MoonIcon />}
42    </button>
43  );
44}
1/* components/ThemeToggle.module.css */
2.themeToggle {
3  background: none;
4  border: none;
5  width: 40px;
6  height: 40px;
7  border-radius: 50%;
8  display: flex;
9  align-items: center;
10  justify-content: center;
11  cursor: pointer;
12  color: var(--foreground);
13  transition: background-color 0.3s ease;
14}
15
16.themeToggle:hover {
17  background-color: var(--muted);
18}

3. Context Provider for Global Theme Access

1// contexts/ThemeContext.tsx
2'use client';
3
4import { createContext, useState, useEffect, useContext, ReactNode } from 'react';
5
6type Theme = 'light' | 'dark';
7
8interface ThemeContextType {
9  theme: Theme;
10  setTheme: (theme: Theme) => void;
11  toggleTheme: () => void;
12}
13
14const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
15
16export function ThemeProvider({ children }: { children: ReactNode }) {
17  const [theme, setTheme] = useState<Theme>('light');
18
19  // Initialize theme based on system preferences and saved settings
20  useEffect(() => {
21    // Check if we are in a browser environment
22    if (typeof window !== 'undefined') {
23      const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
24      const savedTheme = localStorage.getItem('theme') as Theme | null;
25
26      const initialTheme = savedTheme || (prefersDark ? 'dark' : 'light');
27      setTheme(initialTheme);
28      document.documentElement.classList.toggle('dark-theme', initialTheme === 'dark');
29    }
30  }, []);
31
32  // Efekt uruchamiany przy zmianie tematu
33  useEffect(() => {
34    if (typeof window !== 'undefined') {
35      document.documentElement.classList.toggle('dark-theme', theme === 'dark');
36      localStorage.setItem('theme', theme);
37    }
38  }, [theme]);
39
40  // Function to toggle theme
41  const toggleTheme = () => {
42    setTheme(prevTheme => (prevTheme === 'light' ? 'dark' : 'light'));
43  };
44
45  return (
46    <ThemeContext.Provider value={{ theme, setTheme, toggleTheme }}>
47      {children}
48    </ThemeContext.Provider>
49  );
50}
51
52// Hook to use theme context
53export function useTheme() {
54  const context = useContext(ThemeContext);
55  if (context === undefined) {
56    throw new Error('useTheme must be used within a ThemeProvider');
57  }
58  return context;
59}

4. Implementacja w root layout

1// app/layout.tsx
2import { ThemeProvider } from '@/contexts/ThemeContext';
3import './globals.css';
4import type { Metadata } from 'next';
5
6export const metadata: Metadata = {
7  title: 'Metropolis Quantum',
8  description: 'The city of the future at your fingertips',
9};
10
11export default function RootLayout({
12  children,
13}: {
14  children: React.ReactNode;
15}) {
16  return (
17    <html lang="pl">
18      <body>
19        <ThemeProvider>
20          {children}
21        </ThemeProvider>
22      </body>
23    </html>
24  );
25}

5. Using Context in Components

1// components/Header.tsx
2'use client';
3
4import { useTheme } from '@/contexts/ThemeContext';
5import Link from 'next/link';
6import styles from './Header.module.css';
7
8export function Header() {
9  const { theme, toggleTheme } = useTheme();
10
11  return (
12    <header className={styles.header}>
13      <div className={styles.logo}>
14        <Link href="/">Metropolis Quantum</Link>
15      </div>
16      <nav className={styles.nav}>
17        <ul>
18          <li><Link href="/about">O nas</Link></li>
19          <li><Link href="/services">Services</Link></li>
20          <li><Link href="/contact">Kontakt</Link></li>
21        </ul>
22      </nav>
23      <button
24        className={styles.themeToggle}
25        onClick={toggleTheme}
26        aria-label={theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'}
27      >
28        {theme === 'dark' ? '' : ''}
29      </button>
30    </header>
31  );
32}

Zaawansowane implementacje Dark Mode

1. Using next-themes

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

1npm install next-themes
1// app/providers.tsx
2'use client';
3
4import { ThemeProvider } from 'next-themes';
5
6export function Providers({ children }: { children: React.ReactNode }) {
7  return (
8    <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
9      {children}
10    </ThemeProvider>
11  );
12}
1// app/layout.tsx
2import { Providers } from './providers';
3import './globals.css';
4import type { Metadata } from 'next';
5
6export const metadata: Metadata = {
7  title: 'Metropolis Quantum',
8  description: 'The city of the future at your fingertips',
9};
10
11export default function RootLayout({
12  children,
13}: {
14  children: React.ReactNode;
15}) {
16  return (
17    <html lang="pl" suppressHydrationWarning>
18      <body>
19        <Providers>{children}</Providers>
20      </body>
21    </html>
22  );
23}
1/* app/globals.css */
2:root {
3  --background: #ffffff;
4  --foreground: #121212;
5  /* Inne zmienne dla jasnego motywu */
6}
7
8.dark {
9  --background: #121212;
10  --foreground: #f3f4f6;
11  /* Inne zmienne dla ciemnego motywu */
12}
13
14body {
15  background-color: var(--background);
16  color: var(--foreground);
17  transition: background-color 0.3s ease, color 0.3s ease;
18}
1// components/ThemeSwitch.tsx
2'use client';
3
4import { useTheme } from 'next-themes';
5import { useEffect, useState } from 'react';
6
7export function ThemeSwitch() {
8  const [mounted, setMounted] = useState(false);
9  const { theme, setTheme } = useTheme();
10
11  // Efekt uruchamiany po montowaniu komponentu
12  useEffect(() => {
13    setMounted(true);
14  }, []);
15
16  if (!mounted) {
17    return null; // Nie renderuj podczas pierwszego renderowania (SSR)
18  }
19
20  return (
21    <select
22      value={theme}
23      onChange={(e) => setTheme(e.target.value)}
24      className="bg-transparent px-4 py-2 rounded border border-gray-300 dark:border-gray-700"
25    >
26      <option value="system">Ustawienia systemowe</option>
27      <option value="light">Jasny</option>
28      <option value="dark">Ciemny</option>
29    </select>
30  );
31}

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

1// contexts/ThemeContext.tsx
2'use client';
3
4import { createContext, useState, useEffect, useContext, ReactNode } from 'react';
5
6type Theme = 'light' | 'dark' | 'sepia' | 'forest' | 'ocean';
7
8interface ThemeContextType {
9  theme: Theme;
10  setTheme: (theme: Theme) => void;
11}
12
13const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
14
15export function ThemeProvider({ children }: { children: ReactNode }) {
16  const [theme, setTheme] = useState<Theme>('light');
17
18  // Inicjalizacja tematu
19  useEffect(() => {
20    if (typeof window !== 'undefined') {
21      const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
22      const savedTheme = localStorage.getItem('theme') as Theme | null;
23
24      const initialTheme = savedTheme || (prefersDark ? 'dark' : 'light');
25      setTheme(initialTheme);
26
27      // Remove all theme classes
28      document.documentElement.classList.remove('theme-light', 'theme-dark', 'theme-sepia', 'theme-forest', 'theme-ocean');
29      // Add current theme class
30      document.documentElement.classList.add(`theme-${initialTheme}`);
31    }
32  }, []);
33
34  // Efekt uruchamiany przy zmianie tematu
35  useEffect(() => {
36    if (typeof window !== 'undefined') {
37      // Remove all theme classes
38      document.documentElement.classList.remove('theme-light', 'theme-dark', 'theme-sepia', 'theme-forest', 'theme-ocean');
39      // Add current theme class
40      document.documentElement.classList.add(`theme-${theme}`);
41      localStorage.setItem('theme', theme);
42    }
43  }, [theme]);
44
45  return (
46    <ThemeContext.Provider value={{ theme, setTheme }}>
47      {children}
48    </ThemeContext.Provider>
49  );
50}
51
52export function useTheme() {
53  const context = useContext(ThemeContext);
54  if (context === undefined) {
55    throw new Error('useTheme must be used within a ThemeProvider');
56  }
57  return context;
58}
1/* app/globals.css */
2:root {
3  /* Podstawowe zmienne */
4}
5
6/* Light theme (default) */
7.theme-light {
8  --background: #ffffff;
9  --foreground: #121212;
10  --primary: #0070f3;
11  --secondary: #6b7280;
12  --accent: #8b5cf6;
13  --muted: #f3f4f6;
14  --border: #e5e7eb;
15}
16
17/* Ciemny motyw */
18.theme-dark {
19  --background: #121212;
20  --foreground: #f3f4f6;
21  --primary: #3b82f6;
22  --secondary: #9ca3af;
23  --accent: #a78bfa;
24  --muted: #1f2937;
25  --border: #374151;
26}
27
28/* Motyw sepia (przyjazny dla oczu) */
29.theme-sepia {
30  --background: #f9f5eb;
31  --foreground: #433422;
32  --primary: #9c6644;
33  --secondary: #7d6e5d;
34  --accent: #c27b57;
35  --muted: #efe8d9;
36  --border: #d3c6ad;
37}
38
39/* Forest theme */
40.theme-forest {
41  --background: #1e2a20;
42  --foreground: #e9f0db;
43  --primary: #52b788;
44  --secondary: #74c69d;
45  --accent: #95d5b2;
46  --muted: #2d3a2e;
47  --border: #40534e;
48}
49
50/* Motyw oceaniczny */
51.theme-ocean {
52  --background: #0c2333;
53  --foreground: #e0f2fe;
54  --primary: #0ea5e9;
55  --secondary: #38bdf8;
56  --accent: #7dd3fc;
57  --muted: #164e63;
58  --border: #0369a1;
59}
60
61body {
62  background-color: var(--background);
63  color: var(--foreground);
64  transition: background-color 0.3s ease, color 0.3s ease;
65}

3. Animated Transitions Between Themes

1/* app/globals.css */
2body {
3  background-color: var(--background);
4  color: var(--foreground);
5  transition:
6    background-color 0.5s ease,
7    color 0.5s ease,
8    border-color 0.5s ease,
9    box-shadow 0.5s ease;
10}
11
12/* Animation for nested elements */
13* {
14  transition:
15    background-color 0.5s ease,
16    color 0.5s ease,
17    border-color 0.5s ease,
18    fill 0.5s ease,
19    stroke 0.5s ease;
20}

4. Zastosowanie tailwindcss dla trybu ciemnego

Tailwind CSS ma wbudowane wsparcie dla trybu ciemnego:

1// components/Card.tsx
2export function Card({ title, content }) {
3  return (
4    <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">
5      <h3 className="text-xl font-bold text-gray-900 dark:text-white mb-2">{title}</h3>
6      <p className="text-gray-700 dark:text-gray-300">{content}</p>
7    </div>
8  );
9}

Saving and Managing User Preferences

1. Using localStorage

1// utils/userPreferences.ts
2export interface UserPreferences {
3  theme: 'light' | 'dark' | 'system';
4  fontSize: 'small' | 'medium' | 'large';
5  reducedMotion: boolean;
6  highContrast: boolean;
7  language: string;
8}
9
10const defaultPreferences: UserPreferences = {
11  theme: 'system',
12  fontSize: 'medium',
13  reducedMotion: false,
14  highContrast: false,
15  language: 'pl',
16};
17
18export function getUserPreferences(): UserPreferences {
19  if (typeof window === 'undefined') {
20    return defaultPreferences;
21  }
22
23  try {
24    const savedPreferences = localStorage.getItem('userPreferences');
25    return savedPreferences
26      ? { ...defaultPreferences, ...JSON.parse(savedPreferences) }
27      : defaultPreferences;
28  } catch (error) {
29    console.error('Error reading user preferences:', error);
30    return defaultPreferences;
31  }
32}
33
34export function saveUserPreferences(preferences: Partial<UserPreferences>): void {
35  if (typeof window === 'undefined') {
36    return;
37  }
38
39  try {
40    const currentPreferences = getUserPreferences();
41    const updatedPreferences = { ...currentPreferences, ...preferences };
42    localStorage.setItem('userPreferences', JSON.stringify(updatedPreferences));
43  } catch (error) {
44    console.error('Error saving user preferences:', error);
45  }
46}

2. User Preferences with Cookies

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

1// lib/cookies.ts
2import { parseCookies, setCookie } from 'nookies';
3import { UserPreferences, defaultPreferences } from './userPreferences';
4
5export function getUserPreferencesFromCookies(ctx?: any): UserPreferences {
6  const cookies = parseCookies(ctx);
7
8  if (!cookies.userPreferences) {
9    return defaultPreferences;
10  }
11
12  try {
13    return { ...defaultPreferences, ...JSON.parse(cookies.userPreferences) };
14  } catch (error) {
15    console.error('Error parsing user preferences from cookies:', error);
16    return defaultPreferences;
17  }
18}
19
20export function saveUserPreferencesToCookies(
21  preferences: Partial<UserPreferences>,
22  ctx?: any
23): void {
24  const currentPreferences = getUserPreferencesFromCookies(ctx);
25  const updatedPreferences = { ...currentPreferences, ...preferences };
26
27  setCookie(ctx, 'userPreferences', JSON.stringify(updatedPreferences), {
28    maxAge: 365 * 24 * 60 * 60, // 1 rok
29    path: '/',
30    sameSite: 'strict',
31    secure: process.env.NODE_ENV === 'production',
32  });
33}

3. Switches for Different Preferences

1// components/UserPreferencesPanel.tsx
2'use client';
3
4import { useState, useEffect } from 'react';
5import { UserPreferences, getUserPreferences, saveUserPreferences } from '@/utils/userPreferences';
6import styles from './UserPreferencesPanel.module.css';
7
8export function UserPreferencesPanel() {
9  const [preferences, setPreferences] = useState<UserPreferences | null>(null);
10  const [isOpen, setIsOpen] = useState(false);
11
12  useEffect(() => {
13    setPreferences(getUserPreferences());
14  }, []);
15
16  if (!preferences) return null;
17
18  const updatePreference = <K extends keyof UserPreferences>(
19    key: K,
20    value: UserPreferences[K]
21  ) => {
22    const updatedPreferences = { ...preferences, [key]: value };
23    setPreferences(updatedPreferences);
24    saveUserPreferences({ [key]: value });
25
26    // Zastosuj preferencje natychmiast
27    applyPreferences(key, value);
28  };
29
30  const applyPreferences = <K extends keyof UserPreferences>(
31    key: K,
32    value: UserPreferences[K]
33  ) => {
34    switch (key) {
35      case 'theme':
36        const theme = value as 'light' | 'dark' | 'system';
37        const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
38        const effectiveTheme = theme === 'system' ? (prefersDark ? 'dark' : 'light') : theme;
39
40        document.documentElement.classList.remove('theme-light', 'theme-dark');
41        document.documentElement.classList.add(`theme-${effectiveTheme}`);
42        break;
43
44      case 'fontSize':
45        document.documentElement.style.fontSize = {
46          small: '14px',
47          medium: '16px',
48          large: '18px',
49        }[value as string] || '16px';
50        break;
51
52      case 'reducedMotion':
53        document.documentElement.classList.toggle('reduce-motion', value as boolean);
54        break;
55
56      case 'highContrast':
57        document.documentElement.classList.toggle('high-contrast', value as boolean);
58        break;
59
60      case 'language':
61        // In a real application - here you would implement language change
62        break;
63    }
64  };
65
66  return (
67    <>
68      <button
69        className={styles.settingsButton}
70        onClick={() => setIsOpen(!isOpen)}
71        aria-expanded={isOpen}
72      >
73        Preferencje
74      </button>
75
76      {isOpen && (
77        <div className={styles.panel}>
78          <h2>User Preferences</h2>
79
80          <div className={styles.preferenceGroup}>
81            <h3>Motyw</h3>
82            <div className={styles.options}>
83              <label>
84                <input
85                  type="radio"
86                  name="theme"
87                  value="light"
88                  checked={preferences.theme === 'light'}
89                  onChange={() => updatePreference('theme', 'light')}
90                />
91                Jasny
92              </label>
93
94              <label>
95                <input
96                  type="radio"
97                  name="theme"
98                  value="dark"
99                  checked={preferences.theme === 'dark'}
100                  onChange={() => updatePreference('theme', 'dark')}
101                />
102                Ciemny
103              </label>
104
105              <label>
106                <input
107                  type="radio"
108                  name="theme"
109                  value="system"
110                  checked={preferences.theme === 'system'}
111                  onChange={() => updatePreference('theme', 'system')}
112                />
113                Zgodny z systemem
114              </label>
115            </div>
116          </div>
117
118          <div className={styles.preferenceGroup}>
119            <h3>Rozmiar tekstu</h3>
120            <div className={styles.options}>
121              <label>
122                <input
123                  type="radio"
124                  name="fontSize"
125                  value="small"
126                  checked={preferences.fontSize === 'small'}
127                  onChange={() => updatePreference('fontSize', 'small')}
128                />
129                Small
130              </label>
131
132              <label>
133                <input
134                  type="radio"
135                  name="fontSize"
136                  value="medium"
137                  checked={preferences.fontSize === 'medium'}
138                  onChange={() => updatePreference('fontSize', 'medium')}
139                />
140                Medium
141              </label>
142
143              <label>
144                <input
145                  type="radio"
146                  name="fontSize"
147                  value="large"
148                  checked={preferences.fontSize === 'large'}
149                  onChange={() => updatePreference('fontSize', 'large')}
150                />
151                Large
152              </label>
153            </div>
154          </div>
155
156          <div className={styles.preferenceGroup}>
157            <h3>Accessibility</h3>
158            <div className={styles.options}>
159              <label>
160                <input
161                  type="checkbox"
162                  checked={preferences.reducedMotion}
163                  onChange={(e) => updatePreference('reducedMotion', e.target.checked)}
164                />
165                Zredukowane animacje
166              </label>
167
168              <label>
169                <input
170                  type="checkbox"
171                  checked={preferences.highContrast}
172                  onChange={(e) => updatePreference('highContrast', e.target.checked)}
173                />
174                Wysoki kontrast
175              </label>
176            </div>
177          </div>
178
179          <div className={styles.preferenceGroup}>
180            <h3>Language</h3>
181            <select
182              value={preferences.language}
183              onChange={(e) => updatePreference('language', e.target.value)}
184              className={styles.select}
185            >
186              <option value="pl">Polski</option>
187              <option value="en">English</option>
188              <option value="de">Deutsch</option>
189              <option value="fr">Français</option>
190            </select>
191          </div>
192
193          <button
194            className={styles.closeButton}
195            onClick={() => setIsOpen(false)}
196          >
197            Zamknij
198          </button>
199        </div>
200      )}
201    </>
202  );
203}

CSS for Different Preferences

1. CSS Variables for Different Preferences

1/* app/globals.css */
2:root {
3  /* Podstawowe zmienne */
4  --background: #ffffff;
5  --foreground: #121212;
6  /* ... (inne podstawowe kolory) ... */
7
8  /* Zmienne dla rozmiaru tekstu */
9  --font-size-base: 16px;
10  --font-size-small: 0.875rem;
11  --font-size-medium: 1rem;
12  --font-size-large: 1.25rem;
13  --font-size-xl: 1.5rem;
14  --font-size-2xl: 2rem;
15
16  /* Variables for spacing */
17  --spacing-xs: 0.25rem;
18  --spacing-sm: 0.5rem;
19  --spacing-md: 1rem;
20  --spacing-lg: 1.5rem;
21  --spacing-xl: 2rem;
22
23  /* Zmienne dla animacji */
24  --transition-speed: 0.3s;
25}
26
27/* Tryb ciemny */
28.theme-dark {
29  --background: #121212;
30  --foreground: #f3f4f6;
31  /* ... (inne kolory dla trybu ciemnego) ... */
32}
33
34/* Wysoki kontrast */
35.high-contrast {
36  --background: #000000;
37  --foreground: #ffffff;
38  --primary: #ffff00;
39  --secondary: #00ffff;
40  --accent: #ff00ff;
41  --muted: #333333;
42  --border: #ffffff;
43}
44
45/* Large text */
46html[data-text-size="large"] {
47  --font-size-base: 18px;
48  --font-size-small: 1rem;
49  --font-size-medium: 1.125rem;
50  --font-size-large: 1.375rem;
51  --font-size-xl: 1.75rem;
52  --font-size-2xl: 2.25rem;
53}
54
55/* Zredukowane animacje */
56.reduce-motion * {
57  animation-duration: 0.001s !important;
58  transition-duration: 0.001s !important;
59  scroll-behavior: auto !important;
60}
61
62/* Podstawowe style */
63html {
64  font-size: var(--font-size-base);
65}
66
67body {
68  background-color: var(--background);
69  color: var(--foreground);
70  font-size: var(--font-size-medium);
71  transition:
72    background-color var(--transition-speed) ease,
73    color var(--transition-speed) ease;
74}
75
76a {
77  color: var(--primary);
78  transition: color var(--transition-speed) ease;
79}
80
81.reduce-motion a {
82  transition: none;
83}

2. Component Heights for Different Text Sizes

1/* components/Button.module.css */
2.button {
3  background-color: var(--primary);
4  color: white;
5  border-radius: 0.375rem;
6  font-weight: 500;
7  padding: var(--spacing-sm) var(--spacing-md);
8  font-size: var(--font-size-medium);
9  height: calc(var(--font-size-medium) * 2.5);
10  line-height: 1;
11  display: inline-flex;
12  align-items: center;
13  justify-content: center;
14  transition: background-color var(--transition-speed) ease;
15}
16
17.button:hover {
18  background-color: var(--primary-dark);
19}
20
21.reduce-motion .button {
22  transition: none;
23}

Implementation for Advanced Cases

1. Tryb ciemny z przechowywaniem na serwerze

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

1// app/api/preferences/route.ts
2import { NextRequest, NextResponse } from 'next/server';
3import { getServerSession } from 'next-auth/next';
4import { authOptions } from '@/app/api/auth/[...nextauth]/route';
5import { prisma } from '@/lib/prisma';
6
7export async function GET(req: NextRequest) {
8  const session = await getServerSession(authOptions);
9
10  if (!session || !session.user) {
11    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
12  }
13
14  try {
15    const preferences = await prisma.userPreferences.findUnique({
16      where: { userId: session.user.id },
17    });
18
19    return NextResponse.json(preferences);
20  } catch (error) {
21    return NextResponse.json({ error: 'Failed to fetch preferences' }, { status: 500 });
22  }
23}
24
25export async function POST(req: NextRequest) {
26  const session = await getServerSession(authOptions);
27
28  if (!session || !session.user) {
29    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
30  }
31
32  try {
33    const data = await req.json();
34    const preferences = await prisma.userPreferences.upsert({
35      where: { userId: session.user.id },
36      update: data,
37      create: {
38        userId: session.user.id,
39        ...data,
40      },
41    });
42
43    return NextResponse.json(preferences);
44  } catch (error) {
45    return NextResponse.json({ error: 'Failed to update preferences' }, { status: 500 });
46  }
47}

2. SSR and Pre-rendering with Preference Support

1// proxy.ts (up to Next.js 15: middleware.ts with a middleware function)
2import { NextResponse } from 'next/server';
3import type { NextRequest } from 'next/server';
4
5export function proxy(request: NextRequest) {
6  const response = NextResponse.next();
7
8  // If this is the first request, check if a preference cookie exists
9  const hasPrefsCookie = request.cookies.has('userPreferences');
10
11  // If there is no cookie and this is the first HTML rendering,
12  // add a script detecting system preferences
13  if (!hasPrefsCookie && request.headers.get('sec-fetch-dest') === 'document') {
14    const url = request.nextUrl.clone();
15
16    // If this is an HTML page request and not API or static assets
17    if (!url.pathname.startsWith('/api/') && !url.pathname.match(/\.(js|css|png|jpg|svg)$/)) {
18      const response = NextResponse.next();
19
20      // In a real implementation you can add a script detecting preferences
21      // and setting appropriate cookies before the first rendering
22
23      return response;
24    }
25  }
26
27  return response;
28}
29
30export const config = {
31  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
32};

3. Automatic Dark Mode Switching Based on Time of Day

1// hooks/useDayNightMode.ts
2import { useState, useEffect } from 'react';
3
4export function useDayNightMode() {
5  const [isDarkMode, setIsDarkMode] = useState(false);
6
7  useEffect(() => {
8    // Function checking if it is currently night (from 18:00 to 6:00)
9    const checkIfNight = () => {
10      const hours = new Date().getHours();
11      return hours >= 18 || hours < 6;
12    };
13
14    // Initial setting
15    setIsDarkMode(checkIfNight());
16
17    // Interval checking time every minute
18    const interval = setInterval(() => {
19      setIsDarkMode(checkIfNight());
20    }, 60000);
21
22    return () => clearInterval(interval);
23  }, []);
24
25  return isDarkMode;
26}
1// components/AutomaticThemeToggle.tsx
2'use client';
3
4import { useState, useEffect } from 'react';
5import { useDayNightMode } from '@/hooks/useDayNightMode';
6import styles from './AutomaticThemeToggle.module.css';
7
8export function AutomaticThemeToggle() {
9  const [isAutoMode, setIsAutoMode] = useState(false);
10  const isDarkMode = useDayNightMode();
11  const [manualDarkMode, setManualDarkMode] = useState(false);
12
13  // Effect changing mode based on automatic detection or manual setting
14  useEffect(() => {
15    if (isAutoMode) {
16      document.documentElement.classList.toggle('dark-theme', isDarkMode);
17    } else {
18      document.documentElement.classList.toggle('dark-theme', manualDarkMode);
19    }
20  }, [isAutoMode, isDarkMode, manualDarkMode]);
21
22  return (
23    <div className={styles.themeControls}>
24      <div className={styles.autoToggle}>
25        <label>
26          <input
27            type="checkbox"
28            checked={isAutoMode}
29            onChange={(e) => setIsAutoMode(e.target.checked)}
30          />
31          Automatyczny tryb dzienny/nocny
32        </label>
33      </div>
34
35      {!isAutoMode && (
36        <div className={styles.manualToggle}>
37          <button
38            onClick={() => setManualDarkMode(!manualDarkMode)}
39            className={styles.themeButton}
40          >
41            {manualDarkMode ? 'Switch to light mode' : 'Switch to dark mode'}
42          </button>
43        </div>
44      )}
45
46      <div className={styles.currentMode}>
47        Aktualny tryb: {(isAutoMode ? isDarkMode : manualDarkMode) ? 'Ciemny' : 'Jasny'}
48      </div>
49    </div>
50  );
51}

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

Implementacja z TailwindCSS

Tailwind CSS integrates excellently with dark mode:

1// components/DarkModeExample.tsx
2'use client';
3
4import { useState, useEffect } from 'react';
5
6export function DarkModeExample() {
7  const [darkMode, setDarkMode] = useState(false);
8
9  useEffect(() => {
10    // Check system preferences
11    const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
12    const savedTheme = localStorage.getItem('theme');
13    const initialDarkMode = savedTheme === 'dark' || (!savedTheme && prefersDark);
14
15    setDarkMode(initialDarkMode);
16    document.documentElement.classList.toggle('dark', initialDarkMode);
17  }, []);
18
19  const toggleDarkMode = () => {
20    const newDarkMode = !darkMode;
21    setDarkMode(newDarkMode);
22    document.documentElement.classList.toggle('dark', newDarkMode);
23    localStorage.setItem('theme', newDarkMode ? 'dark' : 'light');
24  };
25
26  return (
27    <div className="p-8 bg-white dark:bg-gray-900 min-h-screen transition-colors duration-300">
28      <div className="max-w-4xl mx-auto">
29        <div className="flex justify-between items-center mb-8">
30          <h1 className="text-3xl font-bold text-gray-900 dark:text-white">
31            Metropolis Quantum
32          </h1>
33
34          <button
35            onClick={toggleDarkMode}
36            className="p-2 rounded-full bg-gray-200 dark:bg-gray-800 text-gray-800 dark:text-white transition-colors duration-300"
37          >
38            {darkMode ? '' : ''}
39          </button>
40        </div>
41
42        <div className="grid gap-6 md:grid-cols-2">
43          <div className="bg-gray-100 dark:bg-gray-800 p-6 rounded-lg shadow-md dark:shadow-gray-800/30">
44            <h2 className="text-xl font-semibold text-gray-800 dark:text-white mb-4">
45              About the City
46            </h2>
47            <p className="text-gray-700 dark:text-gray-300">
48              Metropolis Quantum is the city of the future where technology and humanity coexist in harmony.
49            </p>
50          </div>
51
52          <div className="bg-gray-100 dark:bg-gray-800 p-6 rounded-lg shadow-md dark:shadow-gray-800/30">
53            <h2 className="text-xl font-semibold text-gray-800 dark:text-white mb-4">
54              Services
55            </h2>
56            <ul className="space-y-2 text-gray-700 dark:text-gray-300">
57              <li className="flex items-center">
58                <svg className="w-5 h-5 mr-2 text-blue-600 dark:text-blue-400" fill="currentColor" viewBox="0 0 20 20">
59                  <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" />
60                </svg>
61                Transport kwantowy
62              </li>
63              <li className="flex items-center">
64                <svg className="w-5 h-5 mr-2 text-blue-600 dark:text-blue-400" fill="currentColor" viewBox="0 0 20 20">
65                  <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" />
66                </svg>
67                Holographic Displays
68              </li>
69              <li className="flex items-center">
70                <svg className="w-5 h-5 mr-2 text-blue-600 dark:text-blue-400" fill="currentColor" viewBox="0 0 20 20">
71                  <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" />
72                </svg>
73                Inteligentna infrastruktura
74              </li>
75            </ul>
76          </div>
77        </div>
78
79        <div className="mt-8 bg-blue-600 dark:bg-blue-800 p-6 rounded-lg text-white shadow-lg">
80          <h2 className="text-xl font-semibold mb-4">Join the Community</h2>
81          <p className="mb-4">
82            Discover the amazing possibilities that Metropolis Quantum offers.
83          </p>
84          <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">
85            Sign Up
86          </button>
87        </div>
88      </div>
89    </div>
90  );
91}

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
1// Accessibility & WCAG - Quantum Metropolis
2'use client';
3
4export default function AccessibilityDemo() {
5  return (
6    <div style={{
7      minHeight: '100vh',
8      background: 'linear-gradient(135deg, #0f0f23, #1a1a2e)',
9      color: '#ffffff',
10      padding: '3rem 2rem'
11    }}>
12      <a href="#main-content" style={{
13        position: 'absolute',
14        left: '-9999px',
15        zIndex: 999,
16        padding: '1rem',
17        background: '#64ffda',
18        color: '#0f0f23',
19        textDecoration: 'none',
20        borderRadius: '0.5rem',
21        fontWeight: 600
22      }}>
23        Skip to main content
24      </a>
25
26      <header role="banner" style={{maxWidth: '1200px', margin: '0 auto 3rem'}}>
27        <h1 style={{
28          fontSize: '3rem',
29          textAlign: 'center',
30          background: 'linear-gradient(45deg, #64ffda, #7c4dff)',
31          WebkitBackgroundClip: 'text',
32          WebkitTextFillColor: 'transparent',
33          marginBottom: '1rem'
34        }}>
35          Accessibility & WCAG
36        </h1>
37        <p style={{textAlign: 'center', color: '#b0bec5', fontSize: '1.25rem'}}>
38          Building accessible interfaces for everyone
39        </p>
40      </header>
41
42      <main id="main-content" role="main" style={{maxWidth: '1200px', margin: '0 auto'}}>
43        {/* Semantic HTML */}
44        <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)'}}>
45          <h2 id="semantic-heading" style={{color: '#64ffda', marginBottom: '1.5rem'}}>1. Semantic HTML</h2>
46          <div style={{color: '#b0bec5', lineHeight: 1.8}}>
47            <p style={{marginBottom: '1rem'}}>Use semantic HTML tags instead of generic &lt;div&gt;:</p>
48            <ul style={{paddingLeft: '2rem', marginBottom: '1.5rem'}}>
49              <li>&lt;header&gt;, &lt;nav&gt;, &lt;main&gt;, &lt;article&gt;, &lt;section&gt;, &lt;aside&gt;, &lt;footer&gt;</li>
50              <li>&lt;button&gt; instead of &lt;div onclick&gt;</li>
51              <li>&lt;h1&gt; to &lt;h6&gt; for heading hierarchy</li>
52            </ul>
53            <pre style={{background: 'rgba(0,0,0,0.5)', padding: '1rem', borderRadius: '0.5rem', overflow: 'auto', fontSize: '0.875rem'}}>
54{`<article aria-labelledby="article-title">
55  <h2 id="article-title">Article title</h2>
56  <p>Article content...</p>
57</article>`}
58            </pre>
59          </div>
60        </section>
61
62        {/* ARIA Attributes */}
63        <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)'}}>
64          <h2 id="aria-heading" style={{color: '#64ffda', marginBottom: '1.5rem'}}>2. ARIA Attributes</h2>
65
66          <div style={{marginBottom: '2rem'}}>
67            <h3 style={{color: '#7c4dff', marginBottom: '1rem'}}>Example: Button</h3>
68            <button
69              aria-label="Close dialog window"
70              style={{
71                padding: '0.75rem 2rem',
72                background: 'linear-gradient(135deg, #64ffda, #448aff)',
73                border: 'none',
74                borderRadius: '0.75rem',
75                color: '#0f0f23',
76                fontWeight: 600,
77                cursor: 'pointer',
78                transition: 'all 0.3s ease'
79              }}
80              onFocus={e => e.currentTarget.style.outline = '3px solid #64ffda'}
81              onBlur={e => e.currentTarget.style.outline = 'none'}
82            >
83              Close
84            </button>
85            <pre style={{background: 'rgba(0,0,0,0.5)', padding: '1rem', borderRadius: '0.5rem', overflow: 'auto', fontSize: '0.875rem', marginTop: '1rem'}}>
86{`<button aria-label="Close dialog window">
87  Close
88</button>`}
89            </pre>
90          </div>
91
92          <div>
93            <h3 style={{color: '#7c4dff', marginBottom: '1rem'}}>Example: Progress Bar</h3>
94            <div
95              role="progressbar"
96              aria-valuenow={75}
97              aria-valuemin={0}
98              aria-valuemax={100}
99              aria-label="Loading progress"
100              style={{width: '100%', height: '12px', background: 'rgba(255,255,255,0.1)', borderRadius: '6px', overflow: 'hidden'}}
101            >
102              <div style={{width: '75%', height: '100%', background: 'linear-gradient(90deg, #64ffda, #7c4dff)', borderRadius: '6px', transition: 'width 0.5s ease'}}></div>
103            </div>
104            <pre style={{background: 'rgba(0,0,0,0.5)', padding: '1rem', borderRadius: '0.5rem', overflow: 'auto', fontSize: '0.875rem', marginTop: '1rem'}}>
105{`<div
106  role="progressbar"
107  aria-valuenow="75"
108  aria-valuemin="0"
109  aria-valuemax="100"
110  aria-label="Loading progress"
111>
112  <div style="width: 75%"></div>
113</div>`}
114            </pre>
115          </div>
116        </section>
117
118        {/* Focus Management */}
119        <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)'}}>
120          <h2 id="focus-heading" style={{color: '#64ffda', marginBottom: '1.5rem'}}>3. Focus Styles</h2>
121          <p style={{color: '#b0bec5', marginBottom: '1.5rem', lineHeight: 1.6}}>
122            Always provide visible focus states for keyboard-navigating users
123          </p>
124
125          <div style={{display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: '1rem'}}>
126            <button style={{
127              padding: '1rem',
128              background: 'rgba(100,255,218,0.2)',
129              border: '2px solid #64ffda',
130              borderRadius: '0.75rem',
131              color: '#64ffda',
132              fontWeight: 600,
133              cursor: 'pointer',
134              transition: 'all 0.3s ease'
135            }}
136            onFocus={e => {
137              e.currentTarget.style.outline = '3px solid #64ffda';
138              e.currentTarget.style.outlineOffset = '2px';
139            }}
140            onBlur={e => e.currentTarget.style.outline = 'none'}
141            >
142              Button 1
143            </button>
144
145            <button style={{
146              padding: '1rem',
147              background: 'rgba(124,77,255,0.2)',
148              border: '2px solid #7c4dff',
149              borderRadius: '0.75rem',
150              color: '#7c4dff',
151              fontWeight: 600,
152              cursor: 'pointer',
153              transition: 'all 0.3s ease'
154            }}
155            onFocus={e => {
156              e.currentTarget.style.outline = '3px solid #7c4dff';
157              e.currentTarget.style.outlineOffset = '2px';
158            }}
159            onBlur={e => e.currentTarget.style.outline = 'none'}
160            >
161              Button 2
162            </button>
163
164            <button style={{
165              padding: '1rem',
166              background: 'rgba(255,0,128,0.2)',
167              border: '2px solid #ff0080',
168              borderRadius: '0.75rem',
169              color: '#ff0080',
170              fontWeight: 600,
171              cursor: 'pointer',
172              transition: 'all 0.3s ease'
173            }}
174            onFocus={e => {
175              e.currentTarget.style.outline = '3px solid #ff0080';
176              e.currentTarget.style.outlineOffset = '2px';
177            }}
178            onBlur={e => e.currentTarget.style.outline = 'none'}
179            >
180              Button 3
181            </button>
182          </div>
183
184          <pre style={{background: 'rgba(0,0,0,0.5)', padding: '1rem', borderRadius: '0.5rem', overflow: 'auto', fontSize: '0.875rem', marginTop: '1.5rem'}}>
185{`button:focus {
186  outline: 3px solid var(--color-primary);
187  outline-offset: 2px;
188}`}
189          </pre>
190        </section>
191
192        {/* Color Contrast */}
193        <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)'}}>
194          <h2 id="contrast-heading" style={{color: '#64ffda', marginBottom: '1.5rem'}}>4. Color Contrast (WCAG AA)</h2>
195          <p style={{color: '#b0bec5', marginBottom: '1.5rem', lineHeight: 1.6}}>
196            Minimum contrast ratios according to WCAG:
197          </p>
198
199          <ul style={{color: '#b0bec5', lineHeight: 2, paddingLeft: '2rem', marginBottom: '1.5rem'}}>
200            <li>Normal text: 4.5:1</li>
201            <li>Large text (18pt+ or 14pt+ bold): 3:1</li>
202            <li>UI components & graphics: 3:1</li>
203          </ul>
204
205          <div style={{display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '1.5rem'}}>
206            <div style={{background: '#ffffff', padding: '1.5rem', borderRadius: '0.75rem'}}>
207              <p style={{color: '#212529', fontWeight: 600, marginBottom: '0.5rem'}}>Good Contrast</p>
208              <p style={{color: '#212529', fontSize: '0.875rem'}}>Black text on white background (21:1)</p>
209            </div>
210
211            <div style={{background: '#64ffda', padding: '1.5rem', borderRadius: '0.75rem'}}>
212              <p style={{color: '#0f0f23', fontWeight: 600, marginBottom: '0.5rem'}}>Good Contrast</p>
213              <p style={{color: '#0f0f23', fontSize: '0.875rem'}}>Dark text on cyan background (9.2:1)</p>
214            </div>
215          </div>
216        </section>
217
218        {/* Screen Reader Support */}
219        <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)'}}>
220          <h2 id="sr-heading" style={{color: '#64ffda', marginBottom: '1.5rem'}}>5. Screen Reader Support</h2>
221
222          <div style={{marginBottom: '2rem'}}>
223            <h3 style={{color: '#7c4dff', marginBottom: '1rem'}}>Visually hidden but accessible to SR</h3>
224            <pre style={{background: 'rgba(0,0,0,0.5)', padding: '1rem', borderRadius: '0.5rem', overflow: 'auto', fontSize: '0.875rem'}}>
225{`.sr-only {
226  position: absolute;
227  width: 1px;
228  height: 1px;
229  padding: 0;
230  margin: -1px;
231  overflow: hidden;
232  clip: rect(0, 0, 0, 0);
233  white-space: nowrap;
234  border-width: 0;
235}`}
236            </pre>
237          </div>
238
239          <div>
240            <h3 style={{color: '#7c4dff', marginBottom: '1rem'}}>Live Regions</h3>
241            <pre style={{background: 'rgba(0,0,0,0.5)', padding: '1rem', borderRadius: '0.5rem', overflow: 'auto', fontSize: '0.875rem'}}>
242{`<div
243  aria-live="polite"
244  aria-atomic="true"
245  role="status"
246>
247  Data has been saved
248</div>`}
249            </pre>
250          </div>
251        </section>
252
253        {/* Best Practices Checklist */}
254        <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)'}}>
255          <h2 id="checklist-heading" style={{color: '#64ffda', marginBottom: '1.5rem'}}>Accessibility Checklist</h2>
256          <ul style={{color: '#b0bec5', lineHeight: 2.2, paddingLeft: '2rem', listStyle: 'none'}}>
257            <li>Use semantic HTML</li>
258            <li>Provide appropriate ARIA labels</li>
259            <li>All interactive elements are keyboard accessible</li>
260            <li>Focus states are clearly visible</li>
261            <li>Color contrast meets WCAG AA (4.5:1)</li>
262            <li>Images have alt text</li>
263            <li>Forms have labels and error messages</li>
264            <li>Application works without JavaScript</li>
265            <li>Tested with screen readers</li>
266            <li>Responsive and works on various screen sizes</li>
267          </ul>
268        </section>
269      </main>
270
271      <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'}}>
272        <p>© 2150 Quantum Metropolis • Accessibility First</p>
273      </footer>
274    </div>
275  );
276}

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