Kurs Next.js · Moduł 3: Stylowanie i optymalizacja wizualna

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

19 min czytania ·
W tej lekcji9

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?

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

/* app/globals.css */
:root {
  /* Zmienne dla trybu jasnego */
  --background: #ffffff;
  --foreground: #121212;
  --primary: #0070f3;
  --secondary: #6b7280;
  --accent: #8b5cf6;
  --muted: #f3f4f6;
  --border: #e5e7eb;
}

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

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

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

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

2. Komponent do przełączania trybu

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

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

export function ThemeToggle() {
  // Rozpocznij z neutralną wartością, aby uniknąć mignięcia przy hydratacji
  const [isDarkMode, setIsDarkMode] = useState<boolean | undefined>(undefined);

  useEffect(() => {
    // Sprawdź preferencje systemowe
    const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;

    // Sprawdź zapisane preferencje użytkownika
    const savedTheme = localStorage.getItem('theme');
    const initialTheme = savedTheme || (prefersDark ? 'dark' : 'light');

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

  // Efekt uruchamiany przy zmianie stanu
  useEffect(() => {
    if (isDarkMode === undefined) return;

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

  // Nie renderuj przycisku przed określeniem początkowego stanu
  if (isDarkMode === undefined) return null;

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

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

3. Provider kontekstu dla globalnego dostępu do tematu

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

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

type Theme = 'light' | 'dark';

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

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

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

  // Inicjalizacja tematu na podstawie preferencji systemowych i zapisanych ustawień
  useEffect(() => {
    // Sprawdź czy jesteśmy w środowisku przeglądarki
    if (typeof window !== 'undefined') {
      const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
      const savedTheme = localStorage.getItem('theme') as Theme | null;

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

  // Efekt uruchamiany przy zmianie tematu
  useEffect(() => {
    if (typeof window !== 'undefined') {
      document.documentElement.classList.toggle('dark-theme', theme === 'dark');
      localStorage.setItem('theme', theme);
    }
  }, [theme]);

  // Funkcja do przełączania tematu
  const toggleTheme = () => {
    setTheme(prevTheme => (prevTheme === 'light' ? 'dark' : 'light'));
  };

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

// Hook do używania kontekstu tematu
export function useTheme() {
  const context = useContext(ThemeContext);
  if (context === undefined) {
    throw new Error('useTheme must be used within a ThemeProvider');
  }
  return context;
}

4. Implementacja w root layout

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

export const metadata: Metadata = {
  title: 'Metropolis Quantum',
  description: 'Miasto przyszłości w zasięgu ręki',
};

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

5. Użycie kontekstu w komponentach

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

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

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

  return (
    <header className={styles.header}>
      <div className={styles.logo}>
        <Link href="/">Metropolis Quantum</Link>
      </div>
      <nav className={styles.nav}>
        <ul>
          <li><Link href="/about">O nas</Link></li>
          <li><Link href="/services">Usługi</Link></li>
          <li><Link href="/contact">Kontakt</Link></li>
        </ul>
      </nav>
      <button
        className={styles.themeToggle}
        onClick={toggleTheme}
        aria-label={theme === 'dark' ? 'Przełącz na jasny motyw' : 'Przełącz na ciemny motyw'}
      >
        {theme === 'dark' ? 'Jasny' : 'Ciemny'}
      </button>
    </header>
  );
}

Zaawansowane implementacje Dark Mode

1. Użycie next-themes

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

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

import { ThemeProvider } from 'next-themes';

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

export const metadata: Metadata = {
  title: 'Metropolis Quantum',
  description: 'Miasto przyszłości w zasięgu ręki',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="pl" suppressHydrationWarning>
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}
/* app/globals.css */
:root {
  --background: #ffffff;
  --foreground: #121212;
  /* Inne zmienne dla jasnego motywu */
}

.dark {
  --background: #121212;
  --foreground: #f3f4f6;
  /* Inne zmienne dla ciemnego motywu */
}

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

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

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

  // Efekt uruchamiany po montowaniu komponentu
  useEffect(() => {
    setMounted(true);
  }, []);

  if (!mounted) {
    return null; // Nie renderuj podczas pierwszego renderowania (SSR)
  }

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

2. Wsparcie dla wielu motywów (nie tylko jasny/ciemny)

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

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

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

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

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

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

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

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

      // Usuń wszystkie klasy motywów
      document.documentElement.classList.remove('theme-light', 'theme-dark', 'theme-sepia', 'theme-forest', 'theme-ocean');
      // Dodaj aktualną klasę motywu
      document.documentElement.classList.add(`theme-${initialTheme}`);
    }
  }, []);

  // Efekt uruchamiany przy zmianie tematu
  useEffect(() => {
    if (typeof window !== 'undefined') {
      // Usuń wszystkie klasy motywów
      document.documentElement.classList.remove('theme-light', 'theme-dark', 'theme-sepia', 'theme-forest', 'theme-ocean');
      // Dodaj aktualną klasę motywu
      document.documentElement.classList.add(`theme-${theme}`);
      localStorage.setItem('theme', theme);
    }
  }, [theme]);

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

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

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

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

/* Motyw sepia (przyjazny dla oczu) */
.theme-sepia {
  --background: #f9f5eb;
  --foreground: #433422;
  --primary: #9c6644;
  --secondary: #7d6e5d;
  --accent: #c27b57;
  --muted: #efe8d9;
  --border: #d3c6ad;
}

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

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

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

3. Animowane przejścia między motywami

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

/* Animacja dla elementów zagnieżdżonych */
* {
  transition:
    background-color 0.5s ease,
    color 0.5s ease,
    border-color 0.5s ease,
    fill 0.5s ease,
    stroke 0.5s ease;
}

4. Zastosowanie tailwindcss dla trybu ciemnego

Tailwind CSS ma wbudowane wsparcie dla trybu ciemnego:

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

Zapisywanie i zarządzanie preferencjami użytkownika

1. Używanie localStorage

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

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

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

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

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

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

2. 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:

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

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

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

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

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

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

3. Przełączniki dla różnych preferencji

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

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

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

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

  if (!preferences) return null;

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

    // Zastosuj preferencje natychmiast
    applyPreferences(key, value);
  };

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

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

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

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

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

      case 'language':
        // W rzeczywistej aplikacji - tutaj zaimplementowałbyś zmianę języka
        break;
    }
  };

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

      {isOpen && (
        <div className={styles.panel}>
          <h2>Preferencje użytkownika</h2>

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

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

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

          <div className={styles.preferenceGroup}>
            <h3>Rozmiar tekstu</h3>
            <div className={styles.options}>
              <label>
                <input
                  type="radio"
                  name="fontSize"
                  value="small"
                  checked={preferences.fontSize === 'small'}
                  onChange={() => updatePreference('fontSize', 'small')}
                />
                Mały
              </label>

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

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

          <div className={styles.preferenceGroup}>
            <h3>Dostępność</h3>
            <div className={styles.options}>
              <label>
                <input
                  type="checkbox"
                  checked={preferences.reducedMotion}
                  onChange={(e) => updatePreference('reducedMotion', e.target.checked)}
                />
                Zredukowane animacje
              </label>

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

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

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

CSS dla różnych preferencji

1. Zmienne CSS dla różnych preferencji

/* app/globals.css */
:root {
  /* Podstawowe zmienne */
  --background: #ffffff;
  --foreground: #121212;
  /* ... (inne podstawowe kolory) ... */

  /* Zmienne dla rozmiaru tekstu */
  --font-size-base: 16px;
  --font-size-small: 0.875rem;
  --font-size-medium: 1rem;
  --font-size-large: 1.25rem;
  --font-size-xl: 1.5rem;
  --font-size-2xl: 2rem;

  /* Zmienne dla odstępów */
  --spacing-xs: 0.25rem;
  --spacing-sm: 0.5rem;
  --spacing-md: 1rem;
  --spacing-lg: 1.5rem;
  --spacing-xl: 2rem;

  /* Zmienne dla animacji */
  --transition-speed: 0.3s;
}

/* Tryb ciemny */
.theme-dark {
  --background: #121212;
  --foreground: #f3f4f6;
  /* ... (inne kolory dla trybu ciemnego) ... */
}

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

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

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

/* Podstawowe style */
html {
  font-size: var(--font-size-base);
}

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

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

.reduce-motion a {
  transition: none;
}

2. Wysokość komponentów dla różnych rozmiarów tekstu

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

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

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

Implementacja dla zaawansowanych przypadków

1. Tryb ciemny z przechowywaniem na serwerze

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

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

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

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

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

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

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

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

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

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

2. SSR i prerenderowanie z obsługą preferencji

// proxy.ts (do Next.js 15: middleware.ts z funkcją middleware)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

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

  // Jeśli to pierwsze żądanie, sprawdź czy istnieje cookie z preferencjami
  const hasPrefsCookie = request.cookies.has('userPreferences');

  // Jeśli nie ma cookie a jest to pierwsze renderowanie HTML,
  // dodaj skrypt wykrywający preferencje systemowe
  if (!hasPrefsCookie && request.headers.get('sec-fetch-dest') === 'document') {
    const url = request.nextUrl.clone();

    // Jeśli jest to żądanie strony HTML a nie API lub statycznych zasobów
    if (!url.pathname.startsWith('/api/') && !url.pathname.match(/\.(js|css|png|jpg|svg)$/)) {
      const response = NextResponse.next();

      // W rzeczywistej implementacji możesz dodać skrypt wykrywający preferencje
      // i ustawiający odpowiednie cookie przed pierwszym renderowaniem

      return response;
    }
  }

  return response;
}

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

3. Automatyczne przełączanie trybu ciemnego w zależności od pory dnia

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

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

  useEffect(() => {
    // Funkcja sprawdzająca czy obecnie jest noc (od 18:00 do 6:00)
    const checkIfNight = () => {
      const hours = new Date().getHours();
      return hours >= 18 || hours < 6;
    };

    // Początkowe ustawienie
    setIsDarkMode(checkIfNight());

    // Interwał sprawdzający czas co minutę
    const interval = setInterval(() => {
      setIsDarkMode(checkIfNight());
    }, 60000);

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

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

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

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

  // Efekt zmieniający tryb na podstawie automatycznego wykrywania lub ręcznego ustawienia
  useEffect(() => {
    if (isAutoMode) {
      document.documentElement.classList.toggle('dark-theme', isDarkMode);
    } else {
      document.documentElement.classList.toggle('dark-theme', manualDarkMode);
    }
  }, [isAutoMode, isDarkMode, manualDarkMode]);

  return (
    <div className={styles.themeControls}>
      <div className={styles.autoToggle}>
        <label>
          <input
            type="checkbox"
            checked={isAutoMode}
            onChange={(e) => setIsAutoMode(e.target.checked)}
          />
          Automatyczny tryb dzienny/nocny
        </label>
      </div>

      {!isAutoMode && (
        <div className={styles.manualToggle}>
          <button
            onClick={() => setManualDarkMode(!manualDarkMode)}
            className={styles.themeButton}
          >
            {manualDarkMode ? 'Przełącz na tryb jasny' : 'Przełącz na tryb ciemny'}
          </button>
        </div>
      )}

      <div className={styles.currentMode}>
        Aktualny tryb: {(isAutoMode ? isDarkMode : manualDarkMode) ? 'Ciemny' : 'Jasny'}
      </div>
    </div>
  );
}

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:

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

import { useState, useEffect } from 'react';

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

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

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

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

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

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

        <div className="grid gap-6 md:grid-cols-2">
          <div className="bg-gray-100 dark:bg-gray-800 p-6 rounded-lg shadow-md dark:shadow-gray-800/30">
            <h2 className="text-xl font-semibold text-gray-800 dark:text-white mb-4">
              O mieście
            </h2>
            <p className="text-gray-700 dark:text-gray-300">
              Metropolis Quantum to miasto przyszłości, gdzie technologia i człowiek współistnieją w harmonii.
            </p>
          </div>

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

        <div className="mt-8 bg-blue-600 dark:bg-blue-800 p-6 rounded-lg text-white shadow-lg">
          <h2 className="text-xl font-semibold mb-4">Dołącz do społeczności</h2>
          <p className="mb-4">
            Odkryj niesamowite możliwości, jakie oferuje Metropolis Quantum.
          </p>
          <button className="px-4 py-2 bg-white text-blue-600 dark:bg-gray-800 dark:text-blue-400 rounded-md font-medium hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-300">
            Zarejestruj się
          </button>
        </div>
      </div>
    </div>
  );
}

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:

  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 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
// Accessibility & WCAG - Quantum Metropolis
'use client';

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

      <header role="banner" style={{maxWidth: '1200px', margin: '0 auto 3rem'}}>
        <h1 style={{
          fontSize: '3rem',
          textAlign: 'center',
          background: 'linear-gradient(45deg, #64ffda, #7c4dff)',
          WebkitBackgroundClip: 'text',
          WebkitTextFillColor: 'transparent',
          marginBottom: '1rem'
        }}>
          Accessibility & WCAG
        </h1>
        <p style={{textAlign: 'center', color: '#b0bec5', fontSize: '1.25rem'}}>
          Budowanie dostępnych interfejsów dla wszystkich
        </p>
      </header>

      <main id="main-content" role="main" style={{maxWidth: '1200px', margin: '0 auto'}}>
        {/* Semantic HTML */}
        <section aria-labelledby="semantic-heading" style={{marginBottom: '3rem', background: 'rgba(255,255,255,0.05)', padding: '2rem', borderRadius: '1rem', border: '1px solid rgba(100,255,218,0.2)'}}>
          <h2 id="semantic-heading" style={{color: '#64ffda', marginBottom: '1.5rem'}}>1. Semantic HTML</h2>
          <div style={{color: '#b0bec5', lineHeight: 1.8}}>
            <p style={{marginBottom: '1rem'}}>Używaj semantycznych znaczników HTML zamiast generycznych &lt;div&gt;:</p>
            <ul style={{paddingLeft: '2rem', marginBottom: '1.5rem'}}>
              <li>&lt;header&gt;, &lt;nav&gt;, &lt;main&gt;, &lt;article&gt;, &lt;section&gt;, &lt;aside&gt;, &lt;footer&gt;</li>
              <li>&lt;button&gt; zamiast &lt;div onclick&gt;</li>
              <li>&lt;h1&gt; do &lt;h6&gt; dla hierarchii nagłówków</li>
            </ul>
            <pre style={{background: 'rgba(0,0,0,0.5)', padding: '1rem', borderRadius: '0.5rem', overflow: 'auto', fontSize: '0.875rem'}}>
{`<article aria-labelledby="article-title">
  <h2 id="article-title">Tytuł artykułu</h2>
  <p>Treść artykułu...</p>
</article>`}
            </pre>
          </div>
        </section>

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

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

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

        {/* Focus Management */}
        <section aria-labelledby="focus-heading" style={{marginBottom: '3rem', background: 'rgba(255,255,255,0.05)', padding: '2rem', borderRadius: '1rem', border: '1px solid rgba(100,255,218,0.2)'}}>
          <h2 id="focus-heading" style={{color: '#64ffda', marginBottom: '1.5rem'}}>3. Focus Styles</h2>
          <p style={{color: '#b0bec5', marginBottom: '1.5rem', lineHeight: 1.6}}>
            Zawsze zapewniaj widoczne focus states dla użytkowników nawigujących klawiaturą
          </p>

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

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

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

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

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

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

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

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

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

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

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

        {/* Best Practices Checklist */}
        <section aria-labelledby="checklist-heading" style={{marginBottom: '3rem', background: 'rgba(255,255,255,0.05)', padding: '2rem', borderRadius: '1rem', border: '1px solid rgba(100,255,218,0.2)'}}>
          <h2 id="checklist-heading" style={{color: '#64ffda', marginBottom: '1.5rem'}}>Accessibility Checklist</h2>
          <ul style={{color: '#b0bec5', lineHeight: 2.2, paddingLeft: '2rem', listStyle: 'none'}}>
            <li>Używaj semantycznego HTML</li>
            <li>Zapewnij odpowiednie ARIA labels</li>
            <li>Wszystkie interaktywne elementy są dostępne z klawiatury</li>
            <li>Focus states są wyraźnie widoczne</li>
            <li>Kontrast kolorów spełnia WCAG AA (4.5:1)</li>
            <li>Obrazy mają alt text</li>
            <li>Formularze mają labels i error messages</li>
            <li>Aplikacja działa bez JavaScript</li>
            <li>Testowane z screen readerami</li>
            <li>Responsive i działa na różnych rozmiarach ekranu</li>
          </ul>
        </section>
      </main>

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

Hasła z dokumentacji w tej lekcji

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Którego wyrażenia użyto w lekcji do wykrycia systemowej preferencji ciemnego motywu?

  2. 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:

Przydatne artykuły