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/catchwokółlocalStorage– w trybie prywatnym niektórych przeglądarek albo przy zablokowanym storage sam odczyt potrafi rzucić wyjątkiem. Bezcatchskrypt w<head>wywala się cicho, a motyw przestaje działać.- Skrypt musi być naprawdę inline i przed arkuszami/treścią. Każde
async,deferczy 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 systemem – usuwam 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 systemu | localStorage['theme'] | Motyw strony |
|---|---|---|
| jasny | (brak) | jasny |
| jasny | opposite | ciemny |
| ciemny | (brak) | ciemny |
| ciemny | opposite | jasny |
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-schemew 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, afsGet()waliduje wartość zlocalStorage– śmieciowy zapis wraca do 100%. - Wartość domyślna = brak klucza. Przy 100% usuwam i wpis w
localStorage, i inline-owyfont-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.