Dark Mode i preferencje użytkownika w Next.js 16
Kurs Next.js · Moduł 3: Stylowanie i optymalizacja wizualna
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 16.
Znaczenie Dark Mode i preferencji użytkownika
Dlaczego Dark Mode jest ważny?
- Komfort użytkowania - redukuje zmęczenie oczu, szczególnie w warunkach słabego oświetlenia
- Oszczędność energii - na urządzeniach z ekranami OLED/AMOLED tryb ciemny może znacząco wydłużyć czas pracy baterii
- Dostępność - pomaga użytkownikom z wrażliwością na światło lub określonymi wadami wzroku
- Estetyka - wiele osób preferuje ciemne interfejsy ze względów estetycznych
- 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:
- Rozmiar tekstu - dla poprawy czytelności
- Kontrast - niektórzy użytkownicy potrzebują wyższego kontrastu
- Animacje - opcja redukcji animacji dla osób z zaburzeniami przedsionkowymi
- Preferowany język - wsparcie dla wielu języków
- Układ - opcje zmiany układu (np. gęstość zawartości)
Implementacja Dark Mode w Next.js 16
Next.js 16 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-themes1// 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// proxy.ts (do Next.js 15: middleware.ts z funkcją middleware)
2import { NextResponse } from 'next/server';
3import type { NextRequest } from 'next/server';
4
5export function proxy(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
- Respektuj preferencje systemowe - zawsze sprawdzaj
prefers-color-schemejako domyślne ustawienie - Unikaj mignięć (flash) - zapobiegaj miganiu zawartości podczas przełączania trybów
- Konsekwentne kolory - upewnij się, że wszystkie elementy interfejsu poprawnie reagują na zmianę trybu
- Testuj dokładnie - sprawdź, jak wygląda aplikacja w obu trybach
- Dostępność - pamiętaj, że tryb ciemny to nie tylko kwestia wyglądu, ale również dostępności
- Wydajność - unikaj wielokrotnego przepisywania klas i stylów podczas przełączania
- Sygnalizuj aktywny tryb - jasno oznaczaj, który tryb jest obecnie aktywny
- 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 16 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:
- Respektuj preferencje systemowe - wielu użytkowników ma już ustawiony preferowany tryb w systemie
- Udostępnij prosty przełącznik - umożliw łatwe przełączanie między trybami
- Zapisuj wybory użytkownika - zapamiętuj preferencje między sesjami
- Projektuj dla obu trybów - uwzględnij specyfikę obu trybów już na etapie projektowania
- Zwracaj uwagę na dostępność - tryb ciemny to nie tylko kwestia estetyki, ale również dostępności
- 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 16 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.
Kod do tej lekcji: 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 <div>:</p>
48 <ul style={{paddingLeft: '2rem', marginBottom: '1.5rem'}}>
49 <li><header>, <nav>, <main>, <article>, <section>, <aside>, <footer></li>
50 <li><button> zamiast <div onclick></li>
51 <li><h1> to <h6> 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="Zamknij okno dialogowe"
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="Zamknij okno dialogowe">
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+ lub 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>Zapewnij odpowiednie 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>Testowane z screen readerami</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}Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Którego wyrażenia użyto w lekcji do wykrycia systemowej preferencji ciemnego motywu?
2. Gdzie w lekcji zapisywano wybrany przez użytkownika motyw, aby przetrwał odświeżenie strony?
Zadania praktyczne w grze
- Układanie w pionie
Ułóż kroki implementacji Dark Mode opisane w lekcji w prawidłowej kolejności: