Tworzenie komponentu przełącznika motywu

Podstawowe informacje o tworzeniu adaptacyjnego i łatwo dostępnego komponentu przełączania motywów.

W tym poście przedstawię sposób stworzenia komponentu przełącznika ciemnego i jasnego motywu. Wypróbuj wersję demonstracyjną

demonstracyjny, aby zwiększyć jego widoczność

Jeśli wolisz film, oto wersja tego posta na YouTube:

Przegląd

Witryna może mieć ustawienia pozwalające sterować schematem kolorów, zamiast polegać całkowicie na preferencjach systemowych. Oznacza to, że użytkownicy mogą przeglądać w trybie innym niż preferowany system. Przykład: system użytkownika ma ciemny motyw, ale użytkownik woli wyświetlać witrynę w ciemnym motywie.

Podczas tworzenia tej funkcji należy wziąć pod uwagę kilka kwestii z zakresu inżynierii internetowej. Na przykład jak najszybciej należy poinformować przeglądarkę o tej ustawieniu, aby zapobiec błyskającym kolorom strony, a element sterujący musi najpierw zsynchronizować się z systemem, a następnie zezwolić na wyjątki zapisane po stronie klienta.

Diagram przedstawia podgląd wczytywania strony w języku JavaScript i zdarzeń interakcji z dokumentem, pokazujący 4 ścieżki do ustawienia motywu

Markup

Do przełącznika należy używać obiektu <button>, ponieważ wówczas możesz korzystać ze zdarzeń i funkcji interakcji obsługiwanych przez przeglądarkę, takich jak zdarzenia kliknięcia i możliwość zaznaczenia.

Przycisk

Przycisk wymaga klasy do wykorzystania z CSS i identyfikatora do użycia z JavaScriptu. Ponieważ zawartość przycisku ma postać ikony, a nie tekstu, dodaj atrybut title, aby określić przeznaczenie przycisku. Na koniec dodaj tag [aria-label], który będzie utrzymywać stan przycisku ikony, dzięki czemu czytniki ekranu będą mogły udostępniać stan motywu osobom z wadą wzroku.

<button 
  class="theme-toggle" 
  id="theme-toggle" 
  title="Toggles light & dark" 
  aria-label="auto"
>
  …
</button>

aria-label i aria-live grzecznościowo

Aby poinformować czytniki ekranu o konieczności ogłoszenia zmiany na aria-label, dodaj do przycisku przycisk aria-live="polite".

<button 
  class="theme-toggle" 
  id="theme-toggle" 
  title="Toggles light & dark" 
  aria-label="auto" 
  aria-live="polite"
>
  …
</button>

To uzupełnienie znaczników sygnalizuje czytnikom ekranu, by zamiast aria-live="assertive" uprzejmie informowały użytkownika o zmianie. W przypadku tego przycisku usłyszysz „jasny” lub „ciemny” w zależności od tego, jak wygląda teraz aria-label.

Ikona skalowalnej grafiki wektorowej (SVG)

SVG umożliwia tworzenie wysokiej jakości skalowalnych kształtów przy użyciu minimalnej ilości znaczników. Wchodzenie w interakcję z przyciskiem może aktywować nowe stany wizualne wektorów, dzięki czemu SVG świetnie sprawdza się w przypadku ikon.

W obiekcie <button> umieść te znaczniki SVG:

<svg class="sun-and-moon" aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
  …
</svg>

Do elementu SVG dodano element aria-hidden, dzięki czemu czytniki ekranu mają go zignorować, bo jest oznaczony jako prezentacyjny. To świetny sposób na ozdoby wizualne, takie jak ikona na przycisku. Oprócz wymaganego atrybutu viewBox elementu dodaj wysokość i szerokość z podobnych powodów, dla których obrazy powinny mieć rozmiary wbudowane.

Słońce

Ikona słońca z zanikającymi promieniami słonecznymi i strzałką w kształcie hotpink wskazującej okrąg na środku.

Grafika słońca składa się z okręgu i linii, dla których SVG wygodnie ma się kształty. Element <circle> jest wyśrodkowany, ustawiając właściwości cx i cy na 12, co stanowi połowę rozmiaru widocznego obszaru (24), i wyznaczony promień (r) wynoszący 6, który określa rozmiar.

<svg class="sun-and-moon" aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
  <circle class="sun" cx="12" cy="12" r="6" mask="url(#moon-mask)" fill="currentColor" />
</svg>

Poza tym właściwość mask wskazuje identyfikator elementu SVG, który utworzysz w następnej kolejności, i wskazuje kolor wypełnienia, który pasuje do koloru tekstu strony za pomocą currentColor.

Promienie słońca

Ikona słońca z wygaszonym środkiem słońca i strzałką spod różu wskazującą promienie słoneczne.

Następnie linie promieni słonecznych są dodawane tuż poniżej okręgu, w grupie elementu grupy <g>.

<svg class="sun-and-moon" aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
  <circle class="sun" cx="12" cy="12" r="6" mask="url(#moon-mask)" fill="currentColor" />
  <g class="sun-beams" stroke="currentColor">
    <line x1="12" y1="1" x2="12" y2="3" />
    <line x1="12" y1="21" x2="12" y2="23" />
    <line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
    <line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
    <line x1="1" y1="12" x2="3" y2="12" />
    <line x1="21" y1="12" x2="23" y2="12" />
    <line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
    <line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
  </g>
</svg>

Tym razem zamiast wartości fill o wartości currentColor zostanie ustawiony kresek każdego wiersza. Linie i kształty okręgów tworzą ładne słońce z promieniami.

Księżyc

Aby stworzyć wrażenie płynnego przejścia między światłem (słońcem) a ciemnym (księżycem), księżyc jest uzupełnieniem ikony słońca za pomocą maski SVG.

<svg class="sun-and-moon" aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
  <circle class="sun" cx="12" cy="12" r="6" mask="url(#moon-mask)" fill="currentColor" />
  <g class="sun-beams" stroke="currentColor">
    …
  </g>
  <mask class="moon" id="moon-mask">
    <rect x="0" y="0" width="100%" height="100%" fill="white" />
    <circle cx="24" cy="10" r="6" fill="black" />
  </mask>
</svg>
Grafika z 3 pionowymi warstwami, która pokazuje, jak działa maskowanie. Górna warstwa to biały kwadrat z czarnym okręgiem. Środkowa warstwa to ikona słońca.
Dolna warstwa jest oznaczona etykietą jako wynik i zawiera ikonę słońca z wycięciem w miejscu, gdzie znajduje się czarne kółko w górnej warstwie.

Maski w formacie SVG są potężne, ponieważ białe i czarne umożliwiają usunięcie lub dołączenie części innej grafiki. Ikona słońca zostanie przysłonięta przez kształt księżyca <circle> z maską SVG. Wystarczy, że przesuniesz okrągły kształt do obszaru maski i poza nią.

Co się stanie, jeśli CSS się nie wczyta?

Zrzut ekranu przedstawiający zwykły przycisk przeglądarki z ikoną słońca w środku.

Warto przetestować SVG tak, jakby kod CSS nie został wczytany, aby sprawdzić, czy wynik nie jest bardzo duży ani nie powoduje problemów z układem. Wbudowane atrybuty wysokości i szerokości w SVG oraz zastosowanie atrybutu currentColor zapewniają minimalne reguły stylu, które można stosować w przeglądarce, gdy kod CSS nie zostanie wczytany. Pozwala to uzyskać dobry styl obronny przed turbulencją sieci.

Układ

Komponent przełącznika motywu ma małą powierzchnię, więc do jego układu nie potrzebujesz siatki ani Flexbox. Zamiast tego używane są pozycjonowanie SVG i przekształcenia CSS.

Style

Style: .theme-toggle

Element <button> to kontener na kształty i style ikon. Ten kontekst nadrzędny będzie zawierać adaptacyjne kolory i rozmiary przekazywane do SVG.

Najpierw trzeba zmienić kształt przycisku na okrąg i usunąć domyślne style przycisku:

.theme-toggle {
  --size: 2rem;
  
  background: none;
  border: none;
  padding: 0;

  inline-size: var(--size);
  block-size: var(--size);
  aspect-ratio: 1;
  border-radius: 50%;
}

Następnie dodaj kilka stylów interakcji. Dodaj styl kursora dla użytkowników myszy. Dodaj touch-action: manipulation, aby szybko reagować na dotyk. Usuń półprzezroczyste podświetlenie przycisków na iOS. Na koniec zaznaczmy, że obszar zaznaczenia jest pusty od krawędzi elementu:

.theme-toggle {
  --size: 2rem;

  background: none;
  border: none;
  padding: 0;

  inline-size: var(--size);
  block-size: var(--size);
  aspect-ratio: 1;
  border-radius: 50%;

  cursor: pointer;
  touch-action: manipulation;
  -webkit-tap-highlight-color: transparent;
  outline-offset: 5px;
}

Element SVG wewnątrz przycisku też wymaga niektórych stylów. Plik SVG powinien pasować do rozmiaru przycisku, a dla zachowania wizualnej złożoności zaokrąglij końce linii:

.theme-toggle {
  --size: 2rem;

  background: none;
  border: none;
  padding: 0;

  inline-size: var(--size);
  block-size: var(--size);
  aspect-ratio: 1;
  border-radius: 50%;

  cursor: pointer;
  touch-action: manipulation;
  -webkit-tap-highlight-color: transparent;
  outline-offset: 5px;

  & > svg {
    inline-size: 100%;
    block-size: 100%;
    stroke-linecap: round;
  }
}

Adaptacyjne rozmiary na podstawie zapytania o media hover

Rozmiar przycisku w 2rem jest za mały, co nie stanowi problemu dla użytkowników myszy, ale może być uciążliwe dla precyzyjnego wskaźnika (np. palca). Dopilnuj, aby przycisk spełniał wiele wytycznych dotyczących wielkości obszaru kliknięcia. W tym celu użyj zapytania o multimedia po najechaniu kursorem myszy i określ jego zwiększenie.

.theme-toggle {
  --size: 2rem;
  …
  
  @media (hover: none) {
    --size: 48px;
  }
}

Style SVG Słońce i księżyc

Przycisk przytrzymuje interaktywne elementy komponentu przełącznika motywu, a element SVG w środku zachowuje elementy wizualne i animowane. Tutaj możesz sprawić, że ikona będzie piękna i ożywiona.

Jasny motyw

ALT_TEXT_HERE

Aby animacje skalowania i obracania były wykonywane od środka kształtów SVG, ustaw ich transform-origin: center center. Są tu też adaptacyjne kolory dostarczane przez przycisk. Księżyc i słońce używają do wypełnienia przycisków var(--icon-fill) i var(--icon-fill-hover), a słońce – za pomocą zmiennych kresek.

.sun-and-moon {
  & > :is(.moon, .sun, .sun-beams) {
    transform-origin: center center;
  }

  & > :is(.moon, .sun) {
    fill: var(--icon-fill);

    @nest .theme-toggle:is(:hover, :focus-visible) > & {
      fill: var(--icon-fill-hover);
    }
  }

  & > .sun-beams {
    stroke: var(--icon-fill);
    stroke-width: 2px;

    @nest .theme-toggle:is(:hover, :focus-visible) & {
      stroke: var(--icon-fill-hover);
    }
  }
}

Ciemny motyw

ALT_TEXT_HERE

Style księżyca muszą usunąć promienie słoneczne, przeskalować okrąg słońca w górę i przesunąć maskę koła.

.sun-and-moon {
  @nest [data-theme="dark"] & {
    & > .sun {
      transform: scale(1.75);
    }

    & > .sun-beams {
      opacity: 0;
    }

    & > .moon > circle {
      transform: translateX(-7px);

      @supports (cx: 1) {
        transform: translateX(0);
        cx: 17;
      }
    }
  }
}

Ciemny motyw nie zawiera żadnych zmian kolorów ani przejść. Komponent przycisku nadrzędnego jest właścicielem kolorów, które już się zmieniają w ciemnym i jasnym kontekście. Informacje o przejściu powinny znajdować się w zapytaniu użytkownika dotyczącym ustawień ruchu.

Animacja

Przycisk powinien działać i stanowy, ale w tym momencie nie powinno być żadnych przejść. W kolejnych sekcjach omawiamy definiowanie sposobów i tego przenoszenia.

Udostępnianie zapytań o multimedia i importowanie wygładzania

Aby ułatwić umieszczanie przejść i animacji w ustawieniach ruchu użytkownika w systemie operacyjnym, wtyczka PostCSS Custom Media umożliwia wykorzystanie składni przetworzonej specyfikacji CSS dla zmiennych zapytań o media:

@custom-media --motionOK (prefers-reduced-motion: no-preference);

/* usage example */
@media (--motionOK) {
  .sun {
    transition: transform .5s var(--ease-elastic-3);
  }
}

Aby utworzyć unikalne i łatwe w obsłudze wygładzanie CSS, zaimportuj część wygładzania z Open Props:

@import "https://unpkg.com/open-props/easings.min.css";

/* usage example */
.sun {
  transition: transform .5s var(--ease-elastic-3);
}

Słońce

Przejścia słońca będą bardziej wesołe niż księżycowe, uzyskując ten efekt dzięki wygładzaniu sprężystości. Promienie słoneczne powinny odbijać się w niewielkim stopniu przy obracaniu, a środek Słońca powinien odbijać się w miarę rozwoju.

Domyślne style (jasny motyw) określają przejścia, a style ciemnego motywu – dostosowywanie przejścia do jasnego:

​​.sun-and-moon {
  @media (--motionOK) {
    & > .sun {
      transition: transform .5s var(--ease-elastic-3);
    }

    & > .sun-beams {
      transition: 
        transform .5s var(--ease-elastic-4),
        opacity .5s var(--ease-3)
      ;
    }

    @nest [data-theme="dark"] & {
      & > .sun {
        transform: scale(1.75);
        transition-timing-function: var(--ease-3);
        transition-duration: .25s;
      }

      & > .sun-beams {
        transform: rotateZ(-25deg);
        transition-duration: .15s;
      }
    }
  }
}

W panelu Animacja w Narzędziach deweloperskich w Chrome znajdziesz oś czasu przejścia animacji. Możesz sprawdzić czas trwania całej animacji, elementy oraz czas wygładzania.

Przejście z jasnego na ciemny
Przejście z ciemnego na jasny

Księżyc

Pozycje jasne i ciemne księżyca są już ustawione. Dodaj style przejść w zapytaniu o multimedia --motionOK, aby je ożywić, uwzględniając preferencje użytkownika dotyczące ruchu.

Czas z opóźnieniem i czasem trwania ma kluczowe znaczenie dla zapewnienia sprawności przejścia. Jeśli na przykład słońce jest zbyt wcześnie przyciemnione, przejście nie wydaje się być zabawne ani chaotyczne.

​​.sun-and-moon {
  @media (--motionOK) {
    & .moon > circle {
      transform: translateX(-7px);
      transition: transform .25s var(--ease-out-5);

      @supports (cx: 1) {
        transform: translateX(0);
        cx: 17;
        transition: cx .25s var(--ease-out-5);
      }
    }

    @nest [data-theme="dark"] & {
      & > .moon > circle {
        transition-delay: .25s;
        transition-duration: .5s;
      }
    }
  }
}
Przejście z jasnego na ciemny
Przejście z ciemnego na jasny

Preferuje zmniejszony ruch

W większości wyzwań GUI staram się zachować niektóre animacje, np. przenikanie przezroczystości, z myślą o użytkownikach, którzy wolą ograniczony ruch. Jednak przy natychmiastowych zmianach stanu ten komponent działał lepiej.

JavaScript

Ten komponent wymaga wykonania wielu zadań związanych z JavaScriptem, od zarządzania informacjami ARIA dla czytników ekranu po pobieranie i ustawienie wartości z pamięci lokalnej.

Wczytanie strony

Ważne było, aby podczas wczytywania strony kolory nie migały. Jeśli użytkownik korzystający z ciemnego schematu kolorów wskaże, że preferował ten komponent, to załaduje ponownie stronę. Na początku będzie ona ciemna, a potem będzie błyskać. Aby temu zapobiec, trzeba jak najszybciej blokować JavaScript o celu ustawienia atrybutu HTML data-theme.

<script src="./theme-toggle.js"></script>

W tym celu zwykły tag <script> w dokumencie <head> jest ładowany jako pierwszy, przed wszelkimi znacznikami CSS lub <body>. Gdy przeglądarka napotka taki nieoznaczony skrypt, uruchamia go i uruchamia przed pozostałą częścią kodu HTML. Korzystając z tego sposobu blokowania, można ustawić atrybut HTML przed pojawieniem się głównego kodu CSS w stronie, co zapobiega pojawianiu się rozbłysków lub kolorów.

JavaScript najpierw sprawdza ustawienia użytkownika w pamięci lokalnej, a jeśli nic nie znajdzie w pamięci podręcznej, stosuje je w celu sprawdzenia ustawienia systemu:

const storageKey = 'theme-preference'

const getColorPreference = () => {
  if (localStorage.getItem(storageKey))
    return localStorage.getItem(storageKey)
  else
    return window.matchMedia('(prefers-color-scheme: dark)').matches
      ? 'dark'
      : 'light'
}

Następnie analizowana jest funkcja ustawiania preferencji użytkownika w pamięci lokalnej:

const setPreference = () => {
  localStorage.setItem(storageKey, theme.value)
  reflectPreference()
}

Następnie znajduje się funkcja modyfikowania dokumentu przy użyciu ustawień.

const reflectPreference = () => {
  document.firstElementChild
    .setAttribute('data-theme', theme.value)

  document
    .querySelector('#theme-toggle')
    ?.setAttribute('aria-label', theme.value)
}

Na tym etapie należy zwrócić uwagę na stan analizy dokumentu HTML. Przeglądarka nie wie jeszcze o przycisku „#theme-toggle”, ponieważ tag <head> nie został jeszcze w pełni przeanalizowany. Przeglądarka ma jednak document.firstElementChild, czyli tag <html>. Funkcja próbuje ustawić obie te wartości, aby były synchronizowane, ale przy pierwszym uruchomieniu można ustawić tylko tag HTML. Na początku querySelector nie znajduje niczego, a opcjonalny operator łańcucha nie zawiera błędów składni, gdy nie można go znaleźć, a podejmowana jest próba wywołania funkcji setAttribute.

Następnie funkcja reflectPreference() jest wywoływana od razu, więc dokument HTML ma ustawiony atrybut data-theme:

reflectPreference()

Ten przycisk nadal potrzebuje tego atrybutu, więc poczekaj na zdarzenie wczytania strony, a potem będzie można bezpiecznie wysyłać zapytania, dodawać detektory i ustawiać atrybuty:

window.onload = () => {
  // set on load so screen readers can get the latest value on the button
  reflectPreference()

  // now this script can find and listen for clicks on the control
  document
    .querySelector('#theme-toggle')
    .addEventListener('click', onClick)
}

Przełączanie między trybami

Po kliknięciu przycisku motyw należy zamienić w pamięci JavaScriptu i w dokumencie. Musisz sprawdzić bieżącą wartość motywu i podjąć decyzję dotyczącą nowego stanu. Gdy ustawisz nowy stan, zapisz go i zaktualizuj dokument:

const onClick = () => {
  theme.value = theme.value === 'light'
    ? 'dark'
    : 'light'

  setPreference()
}

Synchronizacja z systemem

Jedynym wyjątkiem jest synchronizacja ze zmieniającymi się preferencjami systemowymi. Jeśli użytkownik zmieni swoje preferencje systemowe w chwili, gdy strona i ten komponent są widoczne, przełącznik motywu zmieni się zgodnie z nowym ustawieniem, tak jakby użytkownik wszedł w interakcję z przełącznikiem motywu w tym samym czasie, gdy przesunął się system.

Aby to zrobić, użyj JavaScriptu i zdarzenia matchMedia wykrywającego zmiany w zapytaniu o multimedia:

window
  .matchMedia('(prefers-color-scheme: dark)')
  .addEventListener('change', ({matches:isDark}) => {
    theme.value = isDark ? 'dark' : 'light'
    setPreference()
  })
Zmiana preferencji systemowych macOS powoduje zmianę stanu przełącznika motywu

Podsumowanie

Wiesz już, jak to zrobiłem, więc jak to zrobisz 🙂

Stwórzmy różne metody i nauczmy się wszystkiego, jak rozwijać się w internecie. Utwórz demonstrację i udostępnię mi linki na Twitterze, a dodam ją do sekcji remiksów w ramach społeczności poniżej.

Remiksy społeczności