Przejdź do treści

Ciemny motyw i A−/A+ bez migotania – jak to działa na tej stronie

W prawym górnym rogu tej strony siedzą trzy przyciski: A−, A+ i przełącznik motywu. Brzmi jak zadanie na kwadrans – i faktycznie byłoby, gdyby nie warunki, które sam sobie postawiłem przy migracji na Astro:

  • zero mignięcia przy wejściu na stronę (żadnego błysku białego tła przed załadowaniem ciemnego motywu),
  • szanowanie ustawień systemu – nie tylko przy pierwszej wizycie, ale też gdy system zmieni motyw w trakcie przeglądania (np. automatyczne przełączenie o zmierzchu),
  • pamięć wyboru użytkownika między wizytami,
  • sensowne zachowanie bez JavaScriptu,
  • zero bibliotek – strona jest statykiem i ma nim pozostać.

Po drodze wyszedł jeden nieoczywisty patent, którego nie widziałem w typowych tutorialach: w localStorage nie zapisuję wybranego koloru, tylko relację do systemu. Po kolei.

Problem FOUC: motyw musi być ustawiony przed pierwszym renderem

Naiwna wersja – zwykły <script> na końcu <body>, który czyta localStorage i ustawia klasę na <html> – działa, ale z brzydkim efektem ubocznym. Przeglądarka najpierw maluje stronę w domyślnym (jasnym) motywie, a dopiero potem skrypt przełącza na ciemny. Ten błysk to FOUC (flash of unstyled content) i na ciemnym motywie razi po oczach.

Rozwiązanie jest jedno: krótki inline-skrypt w <head>, który wykona się synchronicznie, zanim przeglądarka zacznie malować. U mnie (w Astro to is:inline, żeby skrypt nie był bundlowany ani przenoszony) wygląda tak:

<!-- Motyw przed renderem (bez migotania) -->
<script>
  (function () {
    try {
      var m = localStorage.getItem('theme');
      var s = matchMedia('(prefers-color-scheme: dark)').matches;
      var d = (m === 'opposite') ? !s : s;
      document.documentElement.dataset.theme = d ? 'dark' : 'light';
    } catch (e) {}
  })();
</script>

Drugi, bliźniaczy skrypt robi to samo dla rozmiaru tekstu:

<!-- Rozmiar tekstu przed renderem -->
<script>
  (function () {
    try {
      var f = localStorage.getItem('fs');
      if (f) document.documentElement.style.fontSize = f + '%';
    } catch (e) {}
  })();
</script>

Dwa detale, o których łatwo zapomnieć:

  • try/catch wokół localStorage – w trybie prywatnym niektórych przeglądarek albo przy zablokowanym storage sam odczyt potrafi rzucić wyjątkiem. Bez catch skrypt w <head> wywala się cicho, a motyw przestaje działać.
  • Skrypt musi być naprawdę inline i przed arkuszami/treścią. Każde async, defer czy zewnętrzny plik przywraca migotanie.

Tajemnicze m === 'opposite' w pierwszym skrypcie to właśnie sedno wpisu.

Gwóźdź programu: zapisuję 'opposite', nie kolor

Typowy tutorial przełącznika motywu zapisuje w localStorage wprost 'dark' albo 'light'. I to ma paskudny skutek uboczny: po pierwszym kliknięciu strona na zawsze przestaje reagować na system. Użytkownik, któremu OS przełącza motyw automatycznie o zmierzchu, dostaje stronę zamrożoną w jednym kolorze – bo kiedyś, raz, kliknął przełącznik.

Popularna odpowiedź na ten problem to trzy stany: jasny / ciemny / auto. Działa, ale UI robi się mylący – przycisk przechodzi przez stan „auto”, którego efekt zależy od czegoś niewidocznego na ekranie, i trzeba tłumaczyć użytkownikowi, co właściwie wybrał.

Moje rozwiązanie ma dwa stany na przycisku, ale oba względem systemu:

  • klik zawsze robi to, czego oczekujesz: jasny ↔ ciemny;
  • jeśli wynik jest zgodny z systememusuwam klucz z localStorage (strona wraca do „podążaj za systemem”);
  • jeśli wynik jest różny od systemu – zapisuję literalnie 'opposite' („odwrotnie niż system”).

Cała tabela stanów mieści się w czterech wierszach:

Motyw systemulocalStorage['theme']Motyw strony
jasny(brak)jasny
jasnyoppositeciemny
ciemny(brak)ciemny
ciemnyoppositejasny

Kod obsługi kliknięcia:

const mqDark = matchMedia('(prefers-color-scheme: dark)');

document.querySelector('.theme-toggle')?.addEventListener('click', () => {
  const nextDark = document.documentElement.dataset.theme !== 'dark';
  document.documentElement.dataset.theme = nextDark ? 'dark' : 'light';
  try {
    if (nextDark === mqDark.matches) localStorage.removeItem('theme');
    else localStorage.setItem('theme', 'opposite');
  } catch (e) {}
});

A ponieważ oba stany są względne, listener na zmianę motywu systemu zawsze ma sens – nie ma stanu „zamrożonego”, który trzeba by omijać:

mqDark.addEventListener('change', (e) => {
  let m = null;
  try { m = localStorage.getItem('theme'); } catch (_) {}
  const dark = (m === 'opposite') ? !e.matches : e.matches;
  document.documentElement.dataset.theme = dark ? 'dark' : 'light';
});

Scenariusz, w którym to błyszczy: masz system w trybie „auto” (jasny w dzień, ciemny w nocy), a moją stronę wolisz odwrotnie – czytasz ją wieczorem na jasno. Klikasz raz. Od tej pory strona jest zawsze odwrotna do systemu: w dzień ciemna, w nocy jasna – i przełącza się razem z nim, na żywo, bez przeładowania. Z zapisem absolutnym 'dark'/'light' to niewykonalne.

Jest jeden świadomy kompromis: nie da się wyrazić „zawsze ciemny, niezależnie od systemu”. Uznałem, że to strata pozorna – użytkownik, który zmienia motyw systemu, robi to po coś, a strona uparcie ignorująca tę zmianę jest większym złem niż brak czwartego stanu.

CSS: design tokens i ścieżka bez JavaScriptu

Warstwa CSS to klasyczne design tokens – wszystkie kolory jako zmienne na :root, jasny motyw jako domyślny, ciemny jako nadpisanie:

:root {
  --bg-page: #f7fafc;
  --text: #14202b;
  --accent: #0a6e92;
  /* …reszta tokenów… */
  color-scheme: light;
}
[data-theme="dark"] {
  --bg-page: #0d1117;
  --text: #e6edf3;
  --accent: #2bd4ff;
  /* …te same tokeny, ciemne wartości… */
  color-scheme: dark;
}

I tu niespodzianka: ten sam ciemny blok jest u mnie zdublowany w media query:

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    /* …dokładnie te same ciemne tokeny… */
  }
}

Po co, skoro inline-skrypt i tak zawsze ustawia data-theme? Bo skrypt ustawia go tylko wtedy, gdy JavaScript działa. Przy wyłączonym JS atrybutu nie ma – i wtedy media query z prefers-color-scheme przejmuje pałeczkę: strona podąża za systemem czysto CSS-em. Selektor :root:not([data-theme="light"]) gwarantuje przy tym, że gdy JS działa i użytkownik jawnie wybrał jasny motyw, media query mu tego nie nadpisze. Duplikacja kilkunastu linii to cena za pełny wachlarz: JS + zapis → wybór użytkownika, JS bez zapisu → system, brak JS → system.

Dwa drobiazgi, które robią różnicę w odbiorze:

  • color-scheme w obu blokach. Bez tego przeglądarka renderuje natywne elementy – scrollbary, pola formularzy, <select> – w swoim domyślnym (zwykle jasnym) wariancie i na ciemnej stronie świecą białe suwaki. Jedna deklaracja i natywne kontrolki idą za motywem.
  • Ikona słońce/księżyc przełącza się samym CSS-em – w przycisku siedzą oba SVG, a [data-theme="dark"] chowa jedno i pokazuje drugie. JS zmienia wyłącznie atrybut na <html>; nie dotyka ikon, więc nie ma czego rozsynchronizować:
.theme-toggle .theme-icon { display: none; }
.theme-toggle .icon-sun { display: inline-block; }
[data-theme="dark"] .theme-toggle .icon-sun { display: none; }
[data-theme="dark"] .theme-toggle .icon-moon { display: inline-block; }

A−/A+: jedna właściwość skaluje całą stronę

Regulacja rozmiaru tekstu jest zaskakująco krótka, bo cały układ strony jest zbudowany na rem. Wystarczy więc zmienić font-size na <html> – i wszystko, co jest w rem (typografia, odstępy, kontenery), skaluje się proporcjonalnie:

const FS_STEPS = [90, 100, 112, 125, 140];

const fsGet = () => {
  let v = 100;
  try { v = parseInt(localStorage.getItem('fs') || '100', 10); } catch (e) {}
  return FS_STEPS.includes(v) ? v : 100;
};

const fsApply = (v) => {
  document.documentElement.style.fontSize = v === 100 ? '' : v + '%';
  try {
    (v === 100) ? localStorage.removeItem('fs') : localStorage.setItem('fs', String(v));
  } catch (e) {}
};

const fsStep = (dir) => {
  const i = FS_STEPS.indexOf(fsGet());
  fsApply(FS_STEPS[Math.min(FS_STEPS.length - 1, Math.max(0, i + dir))]);
};

document.querySelector('.fs-dec')?.addEventListener('click', () => fsStep(-1));
document.querySelector('.fs-inc')?.addEventListener('click', () => fsStep(1));

Decyzje projektowe, które się tu kryją:

  • Skala skokowa (90–140% w pięciu krokach) zamiast płynnej. Skoki są przewidywalne, użytkownik po dwóch kliknięciach wie, na czym stoi, a fsGet() waliduje wartość z localStorage – śmieciowy zapis wraca do 100%.
  • Wartość domyślna = brak klucza. Przy 100% usuwam i wpis w localStorage, i inline-owy font-size (pusty string przywraca wartość z arkusza). Stan „nic nie zmieniałem” jest naprawdę czysty, a nie „zapisane 100”.
  • Ten sam wzorzec co przy motywie: inline-skrypt w <head> stosuje zapisany rozmiar przed renderem, więc powiększony tekst też nie migocze.

Od strony dostępności: to uzupełnienie, nie zamiennik zoomu przeglądarki – Ctrl+scroll i ustawienia systemowe działają na tej stronie normalnie i niezależnie (procenty się składają). Kryterium WCAG 1.4.4 (Resize Text) wymaga, żeby tekst dało się powiększyć do 200% bez utraty treści – widoczny na stronie przycisk po prostu obniża próg wejścia dla osób, które o skrótach klawiszowych nie wiedzą. Same kontrolki są przy tym uczciwie opisane dla czytników ekranu: role="group" z aria-label="Rozmiar tekstu" na kontenerze i pełne etykiety na przyciskach („Zmniejsz rozmiar tekstu” / „Powiększ rozmiar tekstu”), bo samo „A−” nic niewidzącemu nie mówi.

Bilans

Całość – motyw z obsługą zmian systemu na żywo, regulacja rozmiaru tekstu, pamięć wyboru, brak migotania – to dwa kilkulinijkowe inline-skrypty w <head> i ~30 linii JS przy kontrolkach. Zero zależności, zero frameworków, a lista wymagań ze wstępu odhaczona w całości.

Motyw to zresztą nie tylko tło i tekst – za kulisami do data-theme dostosowuje się też kolorowanie składni (Shiki renderuje kod w dwóch motywach naraz), jasne zrzuty ekranu dostają w ciemnym trybie automatyczny rewers, a logo pilnuje kontrastu AA w obu wariantach. Ale to już materiał na osobny wpis – zwłaszcza Shiki i doprowadzanie kolorów kodu do zgodności z WCAG zasługują na własną historię.

A jeśli już grzebiesz w źródle tej strony, żeby podejrzeć te skrypty – czeka tam na Ciebie coś jeszcze.