Używamy cookies, żeby zwiększyć Twoje doświadczenia na stronie
CodeWorlds

Dark Mode i preferencje użytkownika w Next.js 15

W Metropolis Quantum, miasto dostosowuje się do potrzeb swoich mieszkańców. W dzień, szklane fasady budynków przepuszczają naturalne światło słoneczne, a holograficzne wyświetlacze działają w trybie o wysokiej jasności. Gdy zapada zmrok, miasto automatycznie przełącza się w tryb nocny - oświetlenie staje się cieplejsze i mniej intensywne, holograficzne wyświetlacze przechodzą w tryb ciemny, by nie męczyć oczu mieszkańców.

W nowoczesnych aplikacjach webowych, podobny mechanizm - Dark Mode (tryb ciemny) - stał się nie tylko elementem estetyki, ale standardem użyteczności i dostępności. W tym rozdziale poznamy sposoby implementacji trybu ciemnego i zarządzania preferencjami użytkowników w aplikacjach Next.js 15.

Znaczenie Dark Mode i preferencji użytkownika

Dlaczego Dark Mode jest ważny?

  1. Komfort użytkowania - redukuje zmęczenie oczu, szczególnie w warunkach słabego oświetlenia
  2. Oszczędność energii - na urządzeniach z ekranami OLED/AMOLED tryb ciemny może znacząco wydłużyć czas pracy baterii
  3. Dostępność - pomaga użytkownikom z wrażliwością na światło lub określonymi wadami wzroku
  4. Estetyka - wiele osób preferuje ciemne interfejsy ze względów estetycznych
  5. Zgodność z systemem - utrzymuje spójność z preferencjami systemu operacyjnego użytkownika

Inne preferencje użytkownika

Oprócz trybu ciemnego, warto uwzględnić również inne preferencje:

  1. Rozmiar tekstu - dla poprawy czytelności
  2. Kontrast - niektórzy użytkownicy potrzebują wyższego kontrastu
  3. Animacje - opcja redukcji animacji dla osób z zaburzeniami przedsionkowymi
  4. Preferowany język - wsparcie dla wielu języków
  5. Układ - opcje zmiany układu (np. gęstość zawartości)

Implementacja Dark Mode w Next.js 15

Next.js 15 oferuje kilka podejść do implementacji trybu ciemnego:

1. CSS i zmienne CSS

Najprostsze podejście wykorzystuje zmienne CSS i selektory klas:

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. Komponent do przełączania trybu

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  // Rozpocznij z neutralną wartością, aby uniknąć mignięcia przy hydratacji
10  const [isDarkMode, setIsDarkMode] = useState<boolean | undefined>(undefined);
11  
12  useEffect(() => {
13    // Sprawdź preferencje systemowe
14    const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
15    
16    // Sprawdź zapisane preferencje użytkownika
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  // Nie renderuj przycisku przed określeniem początkowego stanu
33  if (isDarkMode === undefined) return null;
34  
35  return (
36    <button
37      className={styles.themeToggle}
38      onClick={() => setIsDarkMode(!isDarkMode)}
39      aria-label={isDarkMode ? 'Przełącz na jasny motyw' : 'Przełącz na ciemny motyw'}
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. Provider kontekstu dla globalnego dostępu do tematu

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  // Inicjalizacja tematu na podstawie preferencji systemowych i zapisanych ustawień
20  useEffect(() => {
21    // Sprawdź czy jesteśmy w środowisku przeglądarki
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  // Funkcja do przełączania tematu
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 do używania kontekstu tematu
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: 'Miasto przyszłości w zasięgu ręki',
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. Użycie kontekstu w komponentach

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">Usługi</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' ? 'Przełącz na jasny motyw' : 'Przełącz na ciemny motyw'}
27      >
28        {theme === 'dark' ? '☀️' : '🌙'}
29      </button>
30    </header>
31  );
32}

Zaawansowane implementacje Dark Mode

1. Użycie next-themes

next-themes
to popularna biblioteka, która upraszcza implementację trybu ciemnego:

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: 'Miasto przyszłości w zasięgu ręki',
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. Wsparcie dla wielu motywów (nie tylko jasny/ciemny)

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      // Usuń wszystkie klasy motywów
28      document.documentElement.classList.remove('theme-light', 'theme-dark', 'theme-sepia', 'theme-forest', 'theme-ocean');
29      // Dodaj aktualną klasę motywu
30      document.documentElement.classList.add(`theme-${initialTheme}`);
31    }
32  }, []);
33  
34  // Efekt uruchamiany przy zmianie tematu
35  useEffect(() => {
36    if (typeof window !== 'undefined') {
37      // Usuń wszystkie klasy motywów
38      document.documentElement.classList.remove('theme-light', 'theme-dark', 'theme-sepia', 'theme-forest', 'theme-ocean');
39      // Dodaj aktualną klasę motywu
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/* Jasny motyw (domyślny) */
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/* Motyw leśny */
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. Animowane przejścia między motywami

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/* Animacja dla elementów zagnieżdżonych */
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}

Zapisywanie i zarządzanie preferencjami użytkownika

1. Używanie 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. Preferencje użytkownika z pomocą Cookies

Cookies mogą być używane do przechowywania preferencji między sesjami i są dostępne zarówno na kliencie, jak i na serwerze:

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. Przełączniki dla różnych preferencji

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        // W rzeczywistej aplikacji - tutaj zaimplementowałbyś zmianę języka
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>Preferencje użytkownika</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                Mały
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                Średni
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                Duży
152              </label>
153            </div>
154          </div>
155          
156          <div className={styles.preferenceGroup}>
157            <h3>Dostępność</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>Język</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 dla różnych preferencji

1. Zmienne CSS dla różnych preferencji

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  /* Zmienne dla odstępów */
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/* Duży tekst */
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. Wysokość komponentów dla różnych rozmiarów tekstu

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}

Implementacja dla zaawansowanych przypadków

1. Tryb ciemny z przechowywaniem na serwerze

Dla aplikacji z uwierzytelnianiem, preferencje użytkownika mogą być przechowywane na serwerze:

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 i prerenderowanie z obsługą preferencji

1// middleware.ts
2import { NextResponse } from 'next/server';
3import type { NextRequest } from 'next/server';
4
5export function middleware(request: NextRequest) {
6  const response = NextResponse.next();
7  
8  // Jeśli to pierwsze żądanie, sprawdź czy istnieje cookie z preferencjami
9  const hasPrefsCookie = request.cookies.has('userPreferences');
10  
11  // Jeśli nie ma cookie a jest to pierwsze renderowanie HTML,
12  // dodaj skrypt wykrywający preferencje systemowe
13  if (!hasPrefsCookie && request.headers.get('sec-fetch-dest') === 'document') {
14    const url = request.nextUrl.clone();
15    
16    // Jeśli jest to żądanie strony HTML a nie API lub statycznych zasobów
17    if (!url.pathname.startsWith('/api/') && !url.pathname.match(/.(js|css|png|jpg|svg)$/)) {
18      const response = NextResponse.next();
19      
20      // W rzeczywistej implementacji możesz dodać skrypt wykrywający preferencje 
21      // i ustawiający odpowiednie cookie przed pierwszym renderowaniem
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. Automatyczne przełączanie trybu ciemnego w zależności od pory dnia

1// hooks/useDayNightMode.ts
2import { useState, useEffect } from 'react';
3
4export function useDayNightMode() {
5  const [isDarkMode, setIsDarkMode] = useState(false);
6  
7  useEffect(() => {
8    // Funkcja sprawdzająca czy obecnie jest noc (od 18:00 do 6:00)
9    const checkIfNight = () => {
10      const hours = new Date().getHours();
11      return hours >= 18 || hours < 6;
12    };
13    
14    // Początkowe ustawienie
15    setIsDarkMode(checkIfNight());
16    
17    // Interwał sprawdzający czas co minutę
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  // Efekt zmieniający tryb na podstawie automatycznego wykrywania lub ręcznego ustawienia
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 ? 'Przełącz na tryb jasny' : 'Przełącz na tryb ciemny'}
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}

Najlepsze praktyki

  1. Respektuj preferencje systemowe - zawsze sprawdzaj
    prefers-color-scheme
    jako domyślne ustawienie
  2. Unikaj mignięć (flash) - zapobiegaj miganiu zawartości podczas przełączania trybów
  3. Konsekwentne kolory - upewnij się, że wszystkie elementy interfejsu poprawnie reagują na zmianę trybu
  4. Testuj dokładnie - sprawdź, jak wygląda aplikacja w obu trybach
  5. Dostępność - pamiętaj, że tryb ciemny to nie tylko kwestia wyglądu, ale również dostępności
  6. Wydajność - unikaj wielokrotnego przepisywania klas i stylów podczas przełączania
  7. Sygnalizuj aktywny tryb - jasno oznaczaj, który tryb jest obecnie aktywny
  8. Zapisuj preferencje - zapisuj wybrane preferencje użytkownika między sesjami

Implementacja z TailwindCSS

Tailwind CSS doskonale integruje się z trybem ciemnym:

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    // Sprawdź preferencje systemowe
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              O mieście
46            </h2>
47            <p className="text-gray-700 dark:text-gray-300">
48              Metropolis Quantum to miasto przyszłości, gdzie technologia i człowiek współistnieją w harmonii.
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              Usługi
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                Holograficzne wyświetlacze
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">Dołącz do społeczności</h2>
81          <p className="mb-4">
82            Odkryj niesamowite możliwości, jakie oferuje Metropolis Quantum.
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            Zarejestruj się
86          </button>
87        </div>
88      </div>
89    </div>
90  );
91}

Podsumowanie

Efektywna implementacja trybu ciemnego i zarządzanie preferencjami użytkownika w Next.js 15 stanowią ważny element doświadczenia użytkownika. Podobnie jak Metropolis Quantum dostosowuje swoje systemy do pory dnia i preferencji mieszkańców, nowoczesne aplikacje webowe powinny respektować wybory użytkowników, dbając jednocześnie o dostępność, wydajność i spójność interfejsu.

Najważniejsze punkty:

  1. Respektuj preferencje systemowe - wielu użytkowników ma już ustawiony preferowany tryb w systemie
  2. Udostępnij prosty przełącznik - umożliw łatwe przełączanie między trybami
  3. Zapisuj wybory użytkownika - zapamiętuj preferencje między sesjami
  4. Projektuj dla obu trybów - uwzględnij specyfikę obu trybów już na etapie projektowania
  5. Zwracaj uwagę na dostępność - tryb ciemny to nie tylko kwestia estetyki, ale również dostępności
  6. Integruj z innymi preferencjami - pozwól użytkownikom dostosować również inne aspekty interfejsu

Dzięki nowoczesnym narzędziom, takim jak CSS variables, context API Reacta czy biblioteki takie jak next-themes, implementacja trybu ciemnego i zarządzanie preferencjami użytkownika stają się prostsze i bardziej efektywne. Next.js 15 zapewnia solidne podstawy do tworzenia adaptacyjnych interfejsów, które reagują na preferencje użytkowników i zapewniają optymalne doświadczenie niezależnie od warunków.

Przejdź do CodeWorlds