Strona główna / Artykuły / Porównanie dziewięciu technik trybu ciemnego, od trików z filtrami po pliki cookie serwera

Porównanie dziewięciu technik trybu ciemnego, od trików z filtrami po pliki cookie serwera

Porównaj dziewięć sposobów dodania trybu ciemnego do aplikacji internetowej, od odwracania filtrów po tokeny, funkcję light-dark() i pliki cookie serwera, oraz dowiedz się, jakie błędy potajemnie wprowadza każda z tych metod.

5596 słów

Tryb ciemny jest zazwyczaj przedstawiany jako wybór pomiędzy szybkim rozwiązaniem tymczasowym a właściwą metodą, ale strony produkcyjne wykorzystują co najmniej dziewięć różnych technik, z których każda rozwiązuje tylko część problemu. Aspekty, które dane rozwiązanie pomija, zwykle ujawniają się później w postaci migających stron, uszkodzonych nagłówków lub kolorów, które w milczeniu odmawiają zmiany. Ten przewodnik porządkuje te dziewięć podejść, wyjaśnia, co w każdym z nich jest słuszne, a co błędne, a następnie omawia szczegóły, które sprawiają problemy nawet przy starannym wdrożeniu: błędny temat, właściwość color-scheme, ustawienia trzech stanów, przejścia, zawartość wpleciona oraz projekt palety kolorów.

Przedstawione poniżej stwierdzenia dotyczące zachowania są oparte na materiałach pierwotnych: projektach CSS Working Group, standardzie HTML WHATWG, masowo czytelnym pliku browser-compat-data używanym w MDN, danych dotyczących statusu bazowego oraz kodzie źródłowym biblioteki next-themes, które zostały sprawdzone w połączeniu z bezinterfejsowym Chromium. Dwie popularne przekonania nie wytrzymują takiej analizy, a jedno z zachowań – filter przetwarzający potomki z atrybutem position: fixed – okazuje się znacznie lepszym powodem do unikania sztuczki odwracania kolorów niż ogólne argumenty dotyczące wydajności, które zwykle są podawane.

Ciemny tryb to trzy odrębne problemy

„Dodaj temat ciemny” brzmi jak jedna prośba. W praktyce obejmuje on trzy odrębne kwestie, na tyle niezależne, że nawet jeśli udasz się doskonale w odpowiedzi na jedną z nich, i tak otrzymamy produkt z błędami:

  1. Który temat powinien być wyświetlany? System operacyjny ma określoną preferencję, użytkownik może chcieć ją zmienić, a jednocześnie potrzebuje sposobu na powrót do ustawień systemowych. Prosty przełącznik włączenia/wyłączenia trwale eliminuje tę trzecią opcję.
  2. Jak zmieniają się kolory? Jeden przełącznik musi aktualizować każdą powierzchnię, obramowanie, ikonę i cień, a jego położenie w praktyce określa architekturę CSS.
  3. Kiedy stosowany jest temat? Jeśli decyzja zostanie podjęta po pierwszym narysowaniu strony, użytkownicy widzą, jak kolory zmieniają się na ich oczach.

Każda z poniższych technik odpowiada na część z tych pytań. Wzorzec jest jasny: tak zwane podejścia typu „lazy” zazwyczaj zajmują się drugim pytaniem osobno, całkowicie ignorując pierwsze i trzecie.

Jak odczytać ranking

Trzy najwyżej sklasyfikowane rozwiązania nie są konkurentami. łączą się w jedną konfigurację: tokeny semantyczne stanowią podstawę, light-dark() to bardziej zwięzły sposób na zapisanie tych tokenów, a plik cookie czytany przez serwer służy do dostarczenia wybranego tematu bez użycia Flasha. Miejsca od czwartego do szóstego to rzeczywiste kompromisy, przy których trzeba wybrać jedno rozwiązanie. Miejsca od siódmego do dziewiątego to dług techniczny.

Miejsce 9: odwrócenie całej strony za pomocą filter

Najkrótsza możliwa wersja trybu ciemnego stosuje odwrócenie i rotację koloru do elementu korzeniowego:

html {
  filter: invert(1) hue-rotate(180deg);
}

Natychmiast wprowadza drugą zasadę, która ponownie odwraca każdy element multimedialny, dzięki czemu zdjęcia i filmy znów wyglądają normalnie:

/* now patch back everything it broke */
img, video, canvas, svg, [style*="url("] {
  filter: invert(1) hue-rotate(180deg);
}

Zwykle podnoszonym zarzutem jest kwestia wydajności, której trudno jasno udowodnić. Istnieje jednak znacznie poważniejszy problem. Dokumentacja MDN na temat bloków zawierających to wyraźnie stwierdza: ustawienie filter na cokolwiek innego niż none sprawia, że element staje się blokiem zawierającym dla potomków z position: fixed i position: absolute. Ponadto tworzy to nowy kontekst układania, co po cichu zmienia sposób rozstrzygania wartości z-index dla wszystkich elementów znajdujących się poniżej niego.

Test bez interfejsu użytkownika sprawia, że sprawa staje się bardziej konkretna. Umieść stałą barę wewnątrz z filtrem otaczającego elementu na stronie o wysokości 3000px w Chromium 141, przewiń stronicę o 400px i zmierz, gdzie znajduje się ta barra:

await p.evaluate(() => window.scrollTo(0, 400));
// -> { "fixed_viewportTop": 100, "abs_viewportTop": 100 }
// A truly viewport-fixed element reports top: 0 after any scroll.

Bara pokazuje odchylenie widoku na poziomie 100 zamiast 0, więc nie jest już nieruchoma; przesuwa się wraz z treścią. Gdy filtr zostanie zastosowany do elementu html, uszkodzi to wszystkie przyciski nagłówkowe, nawigację nieruchomą, nakładki modalne, elementy typu toast oraz szuflady na stronie. To jest błąd poprawnościowy, który można odtworzyć w zaledwie kilku liniach kodu, a nie kwestia gustu.

Pozostałe problemy są powszechne:

  • Każdy obiekt rastrowy wymaga odwrócenia kolorystycznego, a logotypy z wbudowanymi kolorami marki nadal wyglądają niewłaściwie, ponieważ obrót o 180 stopni nie stanowi dokładnego odwrócenia w żadnym przestrzeni kolorów.
  • Kolory marki zamieniają się w swoje matematyczne przeciwieństwa zamiast tworzyć zaprojektowaną ciemną paletę kolorów.
  • Żadne autorytatywne wytyczne tego nie zalecają. Wytyczne dotyczące trybu ciemnego na stronie web.dev w żadnym wypadku nie sugerują odwracania całych stron; w przypadku mediów proponuje się filter: grayscale(50%) dla zdjęć oraz invert(100%) wyłącznie dla ikon monochromatycznych.
  • Sama rada Chrome dotycząca automatycznego odwracania kolorów polega na stworzeniu dobrze przygotowanego tematu ciemnego zamiast wyłączenia tej funkcji.
  • Miejsca 8 i 7: podejścia, które działają, dopóki nie przestaną

    Zastępowanie poszczególnych elementów

    Naturalnym pierwszym krokiem jest napisanie wersji ciemnej dla każdego elementu. To niekoniecznie błąd, ale raczej brak ograniczeń. Zaczyna się od stylów jasnych:

    .card       { background: #fff; color: #14161a; }
    .card .btn  { background: #f0f2f5; }
    

    a następnie dodaje się odpowiedniki ciemne pod klasą body:

    body.dark .card            { background: #121212; color: #fff; }
    body.dark .card .btn       { background: #333; }
    body.dark .card .btn:hover { background: #444; }
    

    Ponieważ zasady dla koloru ciemnego muszą przewyższać te dla jasnego, selektory stają się coraz bardziej specyficzne; body.dark .card .btn:hover już od samego początku składa się z czterech elementów. Wartości heksadecymalne również się zmieniają – w jednym pliku występuje #121212, w innym #111, a w skopiowanym komponencie #0f0f0f, przy czym nie ma żadnego centralnego miejsca do automatycznego sprawdzania kontrastu. Podstępny problem skalowania można streścić w jednej linijce: nadpisywania rosną wraz z liczbą komponentów, natomiast tokeny – wraz z liczbą ról, a większość systemów projektowych kończy się na około 12 do 20 rolach.

    Dwa oddzielne pliki stylów

    Ładowanie pliku stylów jasnych i ciemnych za pomocą atrybutów media wydaje się efektywne:

    <link rel="stylesheet" href="light.css" media="(prefers-color-scheme: light)">
    <link rel="stylesheet" href="dark.css"  media="(prefers-color-scheme: dark)">
    

    Nie unika to drugiego pobierania, co jest źródłem błędów. Jak wyjaśnia artykuł na web.dev o preferencjach schematu kolorów, plik stylów, którego zapytanie mediowe nie pasuje, jest nadal pobierany, tylko z najniższym priorytetem, więc nie może konkurować z zasobami, których strona aktualnie potrzebuje. Korzyścią jest krótsza ścieżka krytyczna, a nie mniej bajtów. Ponadto atrybut media odczytuje jedynie ustawienia systemu operacyjnego, więc żadne ręczne zmiany nie mogą na nie wpłynąć; oba pliki z czasem mogą się od siebie oddalać; a narzędzia do łączenia plików mogą niewłaściwie je obsługiwać, jak dokumentuje to problem w Vite.

    Miejsce 6: przełącznik w czystym CSS z użyciem :has()

    Vizualnie ukryty polze wyboru i jego etykieta mogą pełnić rolę przełącznika:

    <input type="checkbox" id="theme" class="sr-only">
    <label for="theme">Dark mode</label>
    

    Korzeń wtedy reaguje na stan pola wyboru za pomocą :has():

    html:has(#theme:checked) {
      color-scheme: dark;
      --bg-surface: #1b1f27;
      --text-1:     #e8e6e3;
      --border:     #2b313c;
    }
    

    Rzeczywiście nie jest potrzebny żaden JavaScript, a :has() osiągnął poziom „szeroko dostępny” 19.06.2026 roku. Istotne są dwa aspekty. Po pierwsze, należy zmienić kolory oraz wartość color-scheme, tak jak to pokazano w przykładzie. Po drugie, stan ten istnieje tylko w DOM-ie, więc za każdym razem przy ładowaniu strony wszystko zaczyna się od nowa i nic nie jest zachowywane. Ponadto nie można w przyzwoity sposób przedstawić trzech stanów – wymagałoby to przycisków radiowych i większej liczby gałęzi selektorów. Nadaje się do demonstracji, CodePen lub dokumentu jednostronicowego, ale nie do produktu.

    Miejsce 5: wariant dark: Tailwind

    Wariant dark: nie jest błędny, po prostu umieszcza decyzje dotyczące kolorów w niewłaściwym miejscu: w szablonach, które są powtarzane przy każdym użyciu.

    <div class="bg-white dark:bg-zinc-900
                text-zinc-900 dark:text-zinc-100
                border-zinc-200 dark:border-zinc-800
                hover:bg-zinc-50 dark:hover:bg-zinc-800">
    

    W rezultacie powstają długie łańcuchy tekstowe klas, które utrudniają audyt, ponieważ wartości ciemne są rozproszone w szablonach. Dodatkowo istnieją tematy takie jak wysoki kontrast lub specjalne skórki marki, które mnożą elementy markup zamiast dodawać tylko jedną warstwę tokenów. Nie pomagają one również w przypadku interfejsu rysowanego przez przeglądarkę i nadal wymagają oddzielnego skryptu blokującego, aby uniknąć efektu błysku.

    Rozwiązanie znajduje się w samym Tailwindzie. Należy powiązać kolory tematów z zmiennymi CSS i ustawić je raz; następnie można używać bg-surface bez żadnego prefiksu dark:. Zacznij od standardowego importu:

    @import "tailwindcss";
    

    Następnie należy zdefiniować własną wariantę, powiązać kolory tematów z odpowiednimi zmiennymi i przypisać każdemu tematowi własne wartości tych zmiennych:

    @custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));@theme {
      --color-surface: var(--surface);
      --color-content: var(--content);
    }:root               { --surface: #f4f5f7; --content: #14161a; color-scheme: light; }
    [data-theme="dark"] { --surface: #1b1f27; --content: #e8e6e3; color-scheme: dark; }
    [data-theme="hc"]   { --surface: #000000; --content: #ffffff; color-scheme: dark; }
    

    Wrappers :where() zostały umieszczone celowo. Nadają wersji ciemnej zerową dodatkową specyfikę, dzięki czemu funkcje dark: nigdy przypadkowo nie nadpisują niespowiązanych stylów. Dodanie tematu o wysokim kontraście wymaga jednej linijki kodu, zamiast przetwarzania każdego szablonu.

    Miejsce 4: tylko przestrzeganie prefers-color-scheme

    Najprostszą i najbardziej niezawodną opcją jest zdefiniowanie domyślnie tokenów dla wersji jasnej, a następnie ich ponowne zdefiniowanie w zapytaniu mediowym:

    :root {
      color-scheme: light;
      --bg-base: #ffffff;  --text-1: #14161a;
    }
    
    @media (prefers-color-scheme: dark) {
      :root {
        color-scheme: dark;
        --bg-base: #12141a;  --text-1: #e8e6e3;
      }
    }
    

    Nie ma żadnego JavaScriptu, żadnego efektu wizualnego typu flash ani niczego do ładowania. To najszybsze rozwiązanie na całej liście, a jego jedyna wada ma decydujące znaczenie dla aplikacji i jest bez znaczenia dla treści: użytkownicy nie mogą zmienić ustawień systemowych.

    Jeśli nikt nigdy nie prosił o funkcję włączania/wyłączania, co jest powszechne w blogach, dokumentacji, dziennikach zmian i stronach marketingowych, zatrzymaj się tutaj. Nic w dalszej części listy nie jest lepsze dla tego typu stron.

    Dwa szczegóły specyfikacji są warte zapamiętania:

    • Media Queries Level 5 ostrzega, że ta funkcja może w przyszłości otrzymać więcej wartości – przykładem jest kolor sepia – i zaleca testowanie poprzez negację: (prefers-color-scheme: dark) w porównaniu z (not (prefers-color-scheme: dark)), zamiast bezpośredniego dopasowywania do light.
    • Wartość no-preference została usunięta. Brakuje jej w obecnej specyfikacji i żaden przeglądarka jej nie implementuje; użytkownik bez określonej preferencji odpowiada light.

    Miejsce 3: light-dark() i jego ukryty tryb awaryjny

    light-dark() zmniejsza mniej więcej o połowę rozmiar pliku tokenów, ponieważ jedna deklaracja zawiera obie wartości:

    :root { color-scheme: light dark; }   /* REQUIRED */
    
    .card {
      background:   #fff;                          /* fallback for old browsers */
      background:   light-dark(#fff, #1b1f27);
      color:        light-dark(#14161a, #e8e6e3);
      border-color: light-dark(#e2e5ea, #2b313c);
    }
    /* a manual override becomes ONE property write */
    [data-theme="dark"]  { color-scheme: dark; }
    [data-theme="light"] { color-scheme: light; }
    

    To jest kompletny system tematów bez żadnego bloku @media ani drugiej reguły :root. Ręczna modyfikacja sprowadza się do ustawienia color-scheme na poziomie korzenia.

    Jednak ten problem kosztuje zespoły godziny pracy. Próba uruchomienia kilku wariantów w Chromium 141 przy wymuszeniu obu schematów kolorystycznych przynosi trzy istotne ustalenia:

    1. Bez ustawienia color-scheme funkcja light-dark() nie robi nic. W systemie ciemnym cicho zwraca kolor jasny, a w konsoli nie pojawia się żaden sygnał wskazujący na problem. To najczęstszy błąd funkcji light-dark(), który pozostaje niewidoczny, dopóki ktoś korzystający z systemu ciemnego go nie zgłosi.
  • Ustawienie color-scheme: dark na elemencie sprawia, że ten bierze drugi argument, niezależnie od ustawień systemu. Dlatego ręczne włączanie lub wyłączanie tej funkcji polega na zmianie jednej właściwości, a nie na zamianie klasy oraz dodaniu oddzielnych zasad.
  • Ustawienie color-scheme: dark na elemencie innym niż korzeń nie daje mu ciemnego tła. Zmienia kolory systemu i wbudowane elementy sterujące, ale tło obrazka Canvas podąża za ustawieniami korzenia. To najpowszechniejsze nieporozumienie dotyczące tej właściwości.
  • Jako weryfikację dla własnej palety kolorów, w trybie ciemnym Chromium oblicza kolor systemowy Canvas jako rgb(18, 18, 18), co odpowiada #121212 – temu samemu kolorowi podstawowemu, który Material zaleca dla tematów ciemnych.

    Zanim na tym polegasz, weź pod uwagę następujące zastrzeżenia:

    • Jest to wartość „nowo dostępna”, a nie „szeroko dostępna”: Chrome i Edge 123, Firefox 120 oraz Safari 17.5 – data nowej dostępności to 2024-05-13, co oznacza, że próg szerokiej dostępności przypada na około 2026-11-13.
    • Nie radzi sobie dobrze w sytuacjach awaryjnych. Przeglądarki bez obsługi odrzucają całą deklarację jako nieważną, dlatego zawsze należy najpierw umieścić prosty fallback dla tej samej właściwości, tak jak to pokazano w przykładzie.
    • Jest to wartość koloru, więc nie może być używana jako warunek w zapytaniach mediów. Użycie jej wewnątrz deklaracji w bloku @media jest dozwolone; to zupełnie inna kwestia.
    • Argumenty dotyczące obrazów, takie jak light-dark(url(a.png), url(b.png)), pojawiły się dopiero w Chrome 150, Firefox 150 oraz Safari 27 według danych dotyczących kompatybilności z czasu pisania tekstu, które są zbyt świeże, by na nich polegać.

    Jedno ostrzeżenie dotyczące dokumentacji: strona opisowa MDN dla light-dark() wymienia Chrome 119 i Safari 17.2, podczas gdy własne dane browser-compat-data MDN oraz Baseline API wskazują wartości 123 i 17.5. Gdy te dane się różnią, należy ufać danym strukturyzowanym i sprawdzić aktualne strony przed podawaniem wersji.

    Miejsce 2: tokeny semantyczne – warstwa niezbędna dla wszystkiego innego

    Główną zasadą jest nadawanie nazwy roli, jaką pełni kolor, a nie samego koloru. Należy zacząć od wartości pierwotnych, czyli surowych wartości palety, do których komponenty nigdy nie odwołują się bezpośrednio:

    /* primitives: raw values, never consumed by components */
    :root {
      --gray-0: #ffffff;  --gray-50: #f4f5f7;  --gray-200: #e2e5ea;
      --gray-600: #55606e; --gray-900: #14161a; --gray-950: #12141a;
      --blue-500: #3b82f6; --blue-400: #60a5fa;
    }
    

    Nad nimi znajduje się warstwa semantyczna. Domyślnie stosuje się wartości jasne; jeden blok [data-theme="dark"] zastępuje je wszystkie, a komponenty korzystają wyłącznie z nazw ról, więc nie muszą wiedzieć, który temat jest aktywny:

    /* semantic roles: light is the default */
    :root {
      color-scheme: light;
      --bg-base:    var(--gray-0);
      --bg-surface: var(--gray-50);
      --text-1:     var(--gray-900);
      --text-2:     var(--gray-600);
      --border:     var(--gray-200);
      --accent:     var(--blue-500);
      --shadow-sm:  0 1px 2px rgb(0 0 0 / 0.08);
    }/* one block flips the whole app */
    [data-theme="dark"] {
      color-scheme: dark;
      --bg-base:    #12141a;   /* grey, not #000 */
      --bg-surface: #1b1f27;   /* lighter = higher up */
      --bg-raised:  #232833;   /* lighter still */
      --text-1:     #e8e6e3;
      --text-2:     #a2acbb;
      --border:     #2b313c;
      --accent:     var(--blue-400);
      --shadow-sm:  0 1px 2px rgb(0 0 0 / 0.5);
    }/* components never know which theme is active */
    .card { background: var(--bg-surface); color: var(--text-1); border: 1px solid var(--border); }
    

    Zwróć uwagę na szczegóły w ciemnym bloku: podstawa ma kolor ciemnoszarego, a nie czarnego, powierzchnie stają się jaśniejsze w miarę wzrostu nad poziomem, akcent przesuwa się na jaśniejszy odcień, a cień staje się intensywniejszy, aby pozostać widocznym.

    Prosty test pokaże, czy nazwa tokena jest odpowiednia: czy potrafisz opisać, kiedy go używać, nie wspominając o kolorze? „Tło podwyższonego panelu” opisuje rolę, natomiast „ciemnoszary” opisuje odcień koloru. Tylko role przetrwają zmianę tematu, ponieważ token o nazwie dosłownie „ciemnoszary” nigdy nie powinien stać się ciemny.

    Dla samego przełącznika atrybut data-theme jest lepszy od klasy .dark. Naturalnie może przechowywać trzy lub więcej wartości, nie powoduje konfliktów z klasami pomocniczymi, a jego zmiana polega jedynie na przypisaniu wartości do document.documentElement.dataset.theme. Aby dowiedzieć się więcej na temat używania niestandardowych właściwości w czasie wykonywania, zapoznaj się z przewodnikiem na blogu dotyczącym tworzenia tematów za pomocą niestandardowych właściwości CSS.

    Miejsce 1: odczytywanie pliku cookie z tematem na serwerze

    Renderyzacja tematu na serwerze to jedyna opcja, która pozwala uniknąć wszystkich czterech typowych problemów: skryptu wstawionego bezpośrednio w kod, efektu „flash”, niezgodności podczas hydratacji oraz wyjątku związанego z polityką bezpieczeństwa treści, ponieważ serwer zna temat jeszcze przed wysłaniem pierwszego bajtu. W projekcie Next.js App Router plik rozkładu korzeniowy importuje narzędzie do obsługi cookie:

    // app/layout.tsx
    import { cookies } from 'next/headers';
    

    i zapisuje przechowywany temat bezpośrednio do elementu html, przywracając domyślnie jasny temat:

    export default async function RootLayout({ children }) {
      const store = await cookies();                 // async since Next 15
      const theme = store.get('theme')?.value ?? 'light';  return (
        <html lang="en" data-theme={theme} style={{ colorScheme: theme }}>
          <body>{children}</body>
        </html>
      );
    }
    

    Zmiana tematu odbywa się w ramach działania serwera, które wymaga tego samego importu:

    // app/actions.ts
    'use server';
    import { cookies } from 'next/headers';
    

    To działanie przechowuje wybór tematu na rok, z zakresem obejmującym całą stronę:

    export async function setTheme(theme: 'light' | 'dark') {
      const store = await cookies();
      store.set('theme', theme, { path: '/', maxAge: 60 * 60 * 24 * 365, sameSite: 'lax' });
    }
    

    Koszty są opisane przez sam Next.js:

    • cookies() to API działające w momencie żądania, więc jego wywołanie w layoutu lub stronie zmienia tę ścieżkę na renderowanie dynamiczne, przez co traci się możliwość statycznego renderowania uprzedniego.
    • Gdy są włączone komponenty cache, wywołanie cookies() poza obszarem <Suspense> również uniemożliwia renderowanie uprzednie.
    • HTTP nie pozwala na ustawienie ciasteczek po rozpoczęciu transmisji danych, więc ciasteczko musi zostać zapisane za pomocą .set w funkcji serwera lub obsłudze ścieżki, nigdy podczas procesu renderowania.
  • Każda prośba zawiera plik cookie, który dodaje około 15 bajtów.
  • Ustawienia systemu operacyjnego są niewidoczne dla serwera, więc wybór systemu musi nadal być rozstrzygnięty na stronie klienta. Praktyczną kombinacją jest użycie pliku cookie do wyraźnych wyborów oraz matchMedia w przypadku systemu operacyjnego.
  • Istnieje wskazówka dla klienta, Sec-CH-Prefers-Color-Scheme, która mogłaby ujawnić preferencje systemu operacyjnego serwerowi. Jest to jedynie projekt od WICG, dostępny wyłącznie w Chromium i nie stanowi standardu, więc należy go traktować co najwyżej jako optymalizację, a nie rozwiązanie ogólne.

    Zapobieganie pokazaniu niewłaściwego tematu

    To jest trzeci problem od początku, a także najczęstszy błąd w trybie ciemnym na dostarczanych stronach. Debata na temat „prawidłowego sposobu w porównaniu z sposobem opóźnionym” zazwyczaj w ogóle o tym nie wspomina. Wykres czasowy pokazuje, dlaczego skrypt opóźniony jest zbyt późny:

    DEFERRED SCRIPT:  [HTML][CSS][PAINT: LIGHT][JS][REPAINT: DARK]   <- user sees it
    BLOCKING INLINE:  [HTML][JS][CSS][PAINT: DARK]                   <- correct first paint
    

    Temat musi zostać ustawiony na elementie html przed pierwszym narysowaniem strony. Rozwiązaniem jest mały skrypt wstawiony bezpośrednio do sekcji head, umieszczony przed plikiem stylów:

    <head>
      <meta charset="utf-8">
      <meta name="color-scheme" content="light dark">
      <script>
        // inline. no src, no defer, no async, no type=module.
        (function () {
          try {
            var s = localStorage.getItem('theme');   // 'light'|'dark'|'system'|null
            var dark = s === 'dark' ||
              ((!s || s === 'system') &&
               matchMedia('(prefers-color-scheme: dark)').matches);
            var el = document.documentElement;
            el.dataset.theme     = dark ? 'dark' : 'light';
            el.style.colorScheme = dark ? 'dark' : 'light';
          } catch (e) { /* storage throws in private mode / sandboxed iframes */ }
        })();
      </script>
      <link rel="stylesheet" href="/app.css">
    </head>
    

    Każdy warunek w tym fragmencie jest istotny. Skrypt musi być w formacie inline i synchroniczny, bez atrybutów src, defer, async ani typu modułu, ponieważ cokolwiek z tego mogłoby sprawić, że przeglądarka najpierw narysuje elementy. Skrypt odczytuje preferencję trójwartościową i ustala wartość system za pomocą matchMedia. Ustawia zarówno odpowiedni atrybut, jak i colorScheme, aby tokeny i interfejs przeglądarki były spójne. Ponadto dostęp do pamięci przechowywania jest otoczony blokami try/catch, ponieważ localStorage rzuca błędy w trybach prywatnego przeglądania oraz w iframe’ach z izolacją.

    Prawdziwym kosztem jest polityka bezpieczeństwa: skrypt inline wymaga ustawienia 'unsafe-inline' lub wartości nonce w polityce CSP. Jeśli żadna z tych opcji nie jest dozwolona, należy zastosować podejście oparte na plikach cookie.

    W Next.js również potrzebny jest suppressHydrationWarning na elemencie html, ponieważ skrypt zmienia swoje atrybuty przed hydratacją przez React, przez co już nie odpowiadają one markupowi serwera. Jak zaznaczono w dokumentacji next-themes, ten flag działa tylko na jednym poziomie głębokości, więc nie ukrywa ostrzeżeń dotyczących hydratacji w żadnym innym miejscu.

    Czytając kod źródłowy next-themes widać, jak niewiele jest potrzebne do zapobiegania błędom związanym z szybkim wyświetlaniem treści. Funkcja ta renderuje element <script dangerouslySetInnerHTML>, którego treścią jest sama funkcja script(), przekształcona na ciąg znaków za pomocą script.toString(), a następnie natychmiast wywołana z argumentami zserializowanymi w formacie JSON. Hook useTheme() zwraca wartości theme, setTheme, resolvedTheme, systemTheme oraz themes, przy czym wartość theme jest undefined podczas renderowania na serwerze. Renderuj swój przycisk włączania/wyłączania na podstawie resolvedTheme po sprawdzeniu, czy element został poprawnie zamontowany, w przeciwnym razie sam przycisk spowoduje błąd hydratacji.

    color-scheme: właściwość, której większość stron nigdy nie ustawia

    Twój plik stylów określa wygląd tego, co napisałeś, ale sam przeglądarka rysuje paski przewijania, elementy sterujące formularzem oraz obszar rysowania znajdujący się za stroną. Specyfikacja CSS Color Adjustment wymaga, aby agent użytkownika dostosował wszystko to do schematu kolorów elementu:

    • standardowe kolory pasków przewijania i interaktywnych elementów interfejsu
    • standardowy wygląd elementów sterujących formularzem
    • dodatkowe elementy interfejsu przeglądarki, np. podkreślenia przy błędach ortograficznych
    • kolory systemowe takie jak Canvas, CanvasText, ButtonFace, Field i AccentColor
    • wynik funkcji light-dark()

    W elemencie korzeniowym schemat dodatkowo kontroluje kolor powierzchni obrazka oraz paski przewijania widoku. Należy go zadeklarować w trzech miejscach. Po pierwsze, tag meta, który parser HTML widzi przed jakimikolwiek regułami CSS:

    <!-- parsed at HTML-parse time, BEFORE any CSS loads -->
    <meta name="color-scheme" content="light dark">
    

    Po drugie, reguły CSS, które utrzymują tę właściwość w zgodności z atrybutem tematu:

    :root               { color-scheme: light dark; }
    [data-theme="dark"] { color-scheme: dark; }
    [data-theme="light"]{ color-scheme: light; }
    

    Po trzecie, w razie potrzeby, mechanizm blokujący, który zapewnia jasny wygląd określonego elementu niezależnie od tematu:

    /* force a widget to stay light regardless */
    .brand-widget       { color-scheme: only light; }
    

    Tag meta nie jest zbędny. Sekcja standardu HTML dotycząca taga meta do określania schematu kolorów istnieje właśnie po to, aby przeglądarka mogła natychmiast narysować tło strony zgodnie z wybranym schematem, bez czekania na pliki stylów. Właściwość CSS staje się dostępna dopiero po pobraniu i zinterpretowaniu pliku stylów; ten okres przerwy powoduje chwilowe pokazanie się białego tła. Standard zezwala również na maksymalnie jeden takiego element meta w dokumencie.

    Dwa dodatkowe problemy:

    • Właściwość ta nie ma żadnego związku z zapytaniami mediowymi. Deklaracja color-scheme: dark nigdy nie powoduje, że prefers-color-scheme: dark będzie spełnione, więc kod, który próbuje wywnioskować jedno na podstawie drugiego, będzie działał błędnie.
  • Słowo kluczowe only informuje przeglądarkę, że nie może ona zmienić schematu elementu. W praktyce służy to do uniemożliwienia Chrome na Androidzie stosowania automatycznego ciemnego tematu. Jego historia kompatybilności jest dziwna: dodano je w Chrome 81, usunięto w 85, a przywrócono w 98.
  • Trzy stany zamiast wartości logicznej

    Gdy tylko opcja staje się wartością logiczną, opcja „podążaj za moim systemem operacyjnym” znika i użytkownik nie może jej przywrócić. Preferencja wymaga trzech wartości: jasna, ciemna i systemowa. Należy zacząć od klucza przechowywania danych oraz listy zapytań mediów:

    const STORAGE_KEY = 'theme';
    const mq = matchMedia('(prefers-color-scheme: dark)');
    

    Pozostała logika stosuje wybraną preferencję, zapisuje ją, odczytuje ponownie przy użyciu system jako wartości domyślnej i nadal śledzi zmiany w systemie operacyjnym tylko wtedy, gdy wybrana jest wartość system:

    function apply(pref) {                     // 'light' | 'dark' | 'system'
      const dark = pref === 'dark' || (pref === 'system' && mq.matches);
      const el = document.documentElement;
      el.dataset.theme     = dark ? 'dark' : 'light';
      el.style.colorScheme = dark ? 'dark' : 'light';
    }function setPreference(pref) {
      try { localStorage.setItem(STORAGE_KEY, pref); } catch (e) {}
      apply(pref);
    }function getPreference() {
      try { return localStorage.getItem(STORAGE_KEY) || 'system'; }
      catch (e) { return 'system'; }
    }// keep following the OS, but ONLY while 'system' is the chosen preference
    mq.addEventListener('change', () => {
      if (getPreference() === 'system') apply('system');
    });apply(getPreference());
    

    Należy używać addEventListener na obiekcie MediaQueryList, a nie addListener. Obiekt MediaQueryList dziedziczy teraz od EventTarget, a metody addListener i removeListener są przestarzałe, mimo że wiele przykładów online nadal je używa. next-themes celowo zachowuje te przestarzałe metody, o czym świadczy komentarz w kodzie źródłowym, aby obsłużyć starsze wersje Safari.

    Kontrola powinna składać się z grupy trzech przycisków typu radio, a nie pola wyboru, ponieważ trzy stany wymagają trzech elementów wejściowych.

    Zatrzymanie rozmycia koloru podczas zmiany

    Jeśli elementy na stronie mają przejścia kolorystyczne, zmiana tematu animuje setki właściwości jednocześnie, co powoduje widoczne rozmycie strony. Rozwiązanie stosowane przez next-themes w opcji disableAnimation wprowadza tymczasowy styl, który wyłącza wszystkie przejścia:

    function disableTransitionsTemporarily(nonce) {
      const css = document.createElement('style');
      if (nonce) css.setAttribute('nonce', nonce);
      css.appendChild(document.createTextNode(
        `*,*::before,*::after{ transition: none !important }`
      ));
      document.head.appendChild(css);
    

    Zwraca on funkcję, która przywraca przejścia kolorystyczne, a pomocnik swapTheme obsługuje zmianę tematu pomiędzy nimi:

      return () => {
        // Deliberate forced synchronous reflow: commit the new colours
        // WHILE transitions are still off.
        (() => window.getComputedStyle(document.body))();
        // Remove on a later task, after the flush has committed.
        setTimeout(() => { document.head.removeChild(css); }, 1);
      };
    }function swapTheme(next) {
      const enable = disableTransitionsTemporarily();
      apply(next);
      enable();
    }
    

    Wywołanie getComputedStyle wygląda jak martwy kod i jest często usuwane. Jest to celowe, wymuszone synchroniczne zapisanie stylu, które utrwala nowe kolory, mimo że przejścia są nadal wyłączone. Usunięcie to jest następnie odkładane na późniejsze zadanie za pomocą setTimeout, po tym jak zapisanie stylu wejdzie w życie. Bez tego wymuszonego ponownego zastosowania stylu oraz opóźnionego usunięcia nadal widoczne jest częściowe rozmycie.

    Jeśli wolisz, aby zmiana była widoczna efektywnie, API View Transitions obsługuje znajomy efekt pokazywania się elementu w kształcie koła. Jest on dostępny od 14.10.2025 w wersji Baseline, razem z Chrome 111, Safari 18 i Firefox 144. Istnieje jedno wymaganie, które łatwo przeoczyć: należy wyłączyć domyślne animacje w snapshotach korzeniowych i ustawić tryb łączenia dla starych snapshotów:

    ::view-transition-old(root) { animation: none; mix-blend-mode: normal; }
    ::view-transition-new(root) { animation: none; }
    

    Należy również uwzględnić użytkowników, którzy proszą o mniej ruchu na ekranie:

    @media (prefers-reduced-motion) {
      ::view-transition-group(*),
      ::view-transition-old(*),
      ::view-transition-new(*) { animation: none !important; }
    }
    

    Nie usuwaj wartości mix-blend-mode: normal ze starych snapshotów. Przy domyślnym trybie łączenia plus-lighter ekran na chwilę staje się mlecznobiały podczas przełączania z ciemnego na jasny kolor, co wygląda jak błąd błysku i jest tak też zgłaszane.

    Czego nadal nie działa po zmianie tokenów

    Zdjęcia i SVG

    Element <picture> z atrybutem media="(prefers-color-scheme: dark)" podąża wyłącznie za ustawieniami systemu operacyjnego. Nie może dostrzec twojego atrybutu tematu ani ustawienia color-scheme, więc ręczne włączanie lub wyłączanie powoduje, że obrazy nie są zsynchronizowane z resztą interfejsu – to powszechny błąd w oprogramowaniu. Obrazy tła zadeklarowane w bloku dla tematu ciemnego faktycznie reagują na to włączanie/wyłączanie. W przypadku plików SVG atrybut currentColor działa w elementach <svg> umieszczonych bezpośrednio w tekście oraz w konstrukcji <svg><use href="…">, ale nie w przypadku plików SVG ładowanych za pomocą <img src> lub funkcji CSS url(), ponieważ są to odrębne dokumenty, które nigdy nie odziedziczą twojego koloru. Możesz albo umieścić zapytania prefers-color-scheme bezpośrednio w pliku SVG, albo użyć atrybutu mask-image w połączeniu z background-color: currentColor.

    Iframe’y

    Specyfikacja dostosowywania kolorów stanowi, że gdy schemat kolorów iframe różni się od schematu korzeniowego wplecionej dokumentacji, przeglądarka musi rysować nieprzezroczyste tło w kolorze Canvas tej dokumentacji zamiast przezroczystego. W praktyce oznacza to pojawienie się białego prostokąta na ciemnej stronie. Ustawienie atrybutu color-scheme dla elementu <iframe> poprawia to pierwsze tło, jednak CSS własnej dokumentacji z innego źródła pozostaje niedostępne. Serwisy YouTube, Stripe Elements, Disqus oraz Turnstile oferują osobne opcje tematyczne; nie istnieje rozwiązanie na poziomie CSS.

    theme-color

    Wsparcie dla mety theme-color jest znacznie słabsze, niż się spodziewają ludzie. Firefox nie obsługuje jej na żadnej platformie; Chrome na komputerach, począwszy od wersji 73, stosuje ją tylko w przypadku zainstalowanych aplikacji typu PWA; Safari przyjął tę funkcję w wersji 15, ale począwszy od Safari 26 uwzględnia ją jedynie w zainstalowanych aplikacjach internetowych. MDN określa ją jako mającą ograniczoną dostępność, a nie status podstawowy. Specyfikacja pozwala również przeglądarcom dostosowywać kolor według własnego uznania, na przykład przygaszając go, aby zachować odpowiedni kontrast, dlatego nie należy polegać na dokładnym wyświetlaniu.

    Zmuszane kolory i preferencje kontrastu

    W trybie wysokiego kontrastu w systemie Windows (forced-colors: active) przeglądarka przejmuje kontrolę nad takimi właściwościami jak background-color, color, border-color, outline-color, text-decoration-color oraz właściwościami SVG fill i stroke. Zarówno box-shadow, jak i text-shadow są ustawiane na none, a color-scheme pozostaje niezmienny i wynosi light dark. Wszystkie efekty 3D oparte na cieniach znikają, dlatego należy je zastąpić obramowaniami w kolorach systemowych:

    @media (forced-colors: active) {
      /* your shadow-based elevation is gone — replace it */
      .card { border: 1px solid CanvasText; box-shadow: none; }
      .btn  { border: 1px solid ButtonText; }
    }
    

    Kolor systemowy, który otrzymuje element, zależy od jego rdzennej semantyki HTML, a nie od roli ARIA; dlatego element <div role="button"> nie otrzymuje właściwości ButtonText. MDN zaleca, aby nie tworzyć odrębnego projektu dla trybu z przymusowymi kolorami, lecz dokonywać jedynie niewielkich dostosowań wpływających na czytelność.

    Często zupełnie pomija się kolejną osię: prefers-contrast, której wartościami są no-preference, more, less oraz custom. Baseline jest powszechnie dostępny od 31.05.2022 i nie zależy od schematu kolorów. Ciemne tematy, które nigdy nie uwzględniają wartości more, stanowią częsty problem z dostępnością.

    Projektowanie ciemnej palety kolorów

    Nie używaj czerni, lecz ciemnoszarego koloru

    Wskazówki Material rekomendują użycie ciemnoszarego koloru zamiast czerni dla ciemnych tłumów i powierzchni, ponieważ szary kolor utrzymuje widoczność cieni i zmniejsza zmęczenie oczu przy lekkim tekście. W praktyce kodowej Google dotyczącej ciemnych tematów wspomina się o kolorze tła: tekst w czystym kolorze #FFFFFF na ciemnym tle może wyglądać, jakby się rozmywał lub drgał, co utrudnia czytelność.

    Należy to sformułować ostrożnie. Często powtarzana teza, że czarny kolor w czystej postaci powoduje efekt „halation”, nie wydaje się być poparta żadnym kontrolowanym badaniem. Potwierdzony jest natomiast efekt rozpraszania światła i wibracji u tekstu w kolorze czystej bieli, a wybór szarości zamiast czerni jest uzasadniony widocznością cieni oraz zmęczeniem oczu.

    Wyrażaj wzniosłość poprzez jasność

    Cienie słabo funkcjonują na ciemnych tematach, dlatego Material kompensuje to, czyniąc powierzchnie jaśniejszymi i nieco bardziej kolorowymi w miarę ich podnoszenia się. Funkcja color-mix() ułatwia uzyskanie takiego efektu na podstawie jednego koloru bazowego:

    [data-theme="dark"] {
      --surface-1: #12141a;
      --surface-2: color-mix(in oklab, var(--surface-1) 92%, white);
      --surface-3: color-mix(in oklab, var(--surface-1) 84%, white);
      --surface-4: color-mix(in oklab, var(--surface-1) 76%, white);
    }
    

    color-mix() jest powszechnie dostępny od 2025-11-09. Należy pamiętać, że system elevation-overlay z Material Design 2 jest przestarzały: dokumentacja Google wskazuje, że te warstwy zostały zastąpione systemem kolorów powierzchni tonalnych i nie są już utrzymywane. Material 3 wykorzystuje role od surfaceContainerLowest do surfaceContainerHighest, a także surfaceDim i surfaceBright. Odnośnik, który podaje stare wartości od 5 procent przy 1dp do 16 procent przy 24dp, odnosi się do przestarzałego systemu.

    Odbarwianie akcentów

    Nasycone akcenty o średnim natężeniu wibrują na tle ciemnych powierzchni. OKLCH sprawia, że dostosowanie jest systematyczne: jego kanał jasności jest percepcyjnie jednolity, więc kroki o równych wartościach liczbowych wyglądają również równomiernie, co jest dokładnie tym, w czym zawodzi HSL i dlaczego ciemne gradienty w HSL stają się mętne pośrodku. Lżejszy, mniej chromatyczny akcent dla ciemnego tematu wygląda tak:

    :root               { --accent: oklch(0.55 0.18 255); }  /* darker tone on light bg */
    [data-theme="dark"] { --accent: oklch(0.72 0.14 255); }  /* lighter, less chroma */
    

    Ponowna weryfikacja kontrastu dla ciemnego tematu

    Paleta, która spełnia wymogi kontrastu w trybie jasnym, nic nie mówi o trybie ciemnym. Wzór na względną jasność z WCAG 2.x jest na tyle krótki, że można go umieścić w skrypcie:

    const lum = ([r, g, b]) => {
      const f = v => (v /= 255) <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
      return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
    };
    

    Stosunek kontrastu dzieli wtedy jasność jaśniejszej powierzchni przez jasność ciemniejszej, przy czym obie wartości są odchylone o 0,05:

    const contrast = (a, b) => {
      const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x);
      return (hi + 0.05) / (lo + 0.05);
    };
    

    Wykonywanie tego testu dla każdego koloru używanego do wyświetlania tekstu pozwala skutecznie wykryć problemy, które umykają uwadze. Weźmy pod uwagę temat w kolorze papieru o ciepłym odcieniu, gdzie wszystko wyglądało dobrze na ekranie: kolor pomarańczowy miał stosunek kontrastu 2,06:1, czerwień używana do podświetlania kluczowych słów – 2,80:1, a złoty do numerów – 2,50:1. Wszystkie trzy kolory zostały zatwierdzone pod kątem estetyki, ale żaden z nich nie spełnia wymogów standardu AA.

    Formuła WCAG 2.x ma znany słaby punkt: traktuje identycznie kolor jasny na tle ciemnym i kolor ciemny na tle jasnym, więc paleta w kolorach ciemnych może uzyskać wysoką ocenę, a mimo to wyglądać oślepiająco. Z tego powodu opracowano standard APCA, ale nie jest on częścią WCAG 2.2 i jak dotąd nie ma statusu normatywnego.

    Co mówią badania na temat trybu ciemnego i czytelności

    Często przedstawia się ten fakt w odwrotnej kolejności. Streszczenie badań przygotowane przez Nielsen Norman Group podaje:

    • Dla osób o normalnym wzroku tryb jasny wykazywał lepsze wyniki we wszystkich pomiarach w badaniach przeprowadzonych przez Piepenbrocka i współpracowników (2013, Ergonomics) oraz Dobresa i współpracowników (2017, Applied Ergonomics). Przewaga ta wzrasta wraz ze zmniejszaniem się rozmiaru czcionki. Wyjaśnienie jest optyczne: ciemny tekst na jasnym tle wytwarza więcej światła, źrenica się kurczy, a mniejsza źrenica oznacza mniej aberracji sferycznych i większą głębię ostrości.
    • Dla osób o słabym wzroku Legge i współpracownicy (1985, Vision Research) stwierdzili, że wszyscy siedmiu uczestników z zamgleniem oczu, szczególnie z zaćmą, czytało szybciej w trybie ciemnym.
    • Z dłuższego punktu widzenia Aleman i współpracownicy (2018, Scientific Reports) sugerują, że ciągłe wystawianie się na tryb jasny może być związane z krótkowzrocznością spowodowaną przerzedzeniem naczyniówki.
    • Praktyczną zaleceniem jest umożliwienie użytkownikom przełączenia się na tryb ciemny, jeśli tego chcą.

    Prawdziwy sens polega na tym, że tryb ciemny służy osobistym preferencjom i określonym potrzebom dostępności; nie przynosi udowodnionego poprawienia czytelności dla ogółu użytkowników. To najsilniejszy argument za kontrolą w trzech stanach zamiast ustawiania trybu ciemnego jako domyślnego.

    Główne wnioski

    • Traktuj tryb ciemny jako trzy problemy: wybór tematu, zmiana kolorów oraz zastosowanie wyboru przed pierwszym narysowaniem elementów.
    • Nazwij tokeny według ról, zamień je w jednym bloku data-theme i utrzymuj color-scheme na poziomie korzenia w zgodzie z nimi, wspierany przez tag meta.
    • Zdecyduj o temacie przed pierwszym narysowaniem, albo za pomocą pliku cookie czytanego przez serwer, albo za pomocą skryptu wstawionego bezpośrednio, i zaakceptuj kompromis związany z CSP, jaki ten skrypt wprowadza.
    • Zaoferuj trzy stany: jasny, ciemny i systemowy, i aktualizuj je tylko wtedy, gdy wybrany jest tryb systemowy.
  • Jeśli nikt nie potrzebuje funkcji włączania/wyłączania, prefers-color-scheme zamiast warstwy tokenów to najszybsze i najprostsze rozwiązanie.
  • Nigdy nie używaj filter: invert() na stronie, którą kontrolujesz – sprawia to, że element korzeniowy staje się blokiem nadrzędnym dla każdego elementu stałego.
  • Ponownie sprawdź kontrast, obrazy, iframy oraz zachowanie kolorów wymuszonych osobno dla tematu ciemnego; zmiana tokenów nie naprawia ich automatycznie.
  • Literatura pokrewna