Dostępność Design

Efekty hover na telefonach: każdy ma odpowiednik dla palca, klawiatury lub przewijania

Telefon nie ma kursora. Jak ta strona zamyka hover w (hover: hover) and (pointer: fine) i daje każdemu efektowi odpowiednik dla dotyku i fokusu.

Autor: Mateusz Muszarski Zaktualizowano 6 min czytania In English

Telefon nie ma kursora, więc efekt hover może na nim nie zadziałać wcale, mignąć przez chwilę albo przykleić się po dotknięciu. Na tej stronie hover, który coś przesuwa, siedzi za @media (hover: hover) and (pointer: fine). Każdy efekt ma też odpowiednik dla dotyku i klawiatury: wciśnięcie, styl fokusu, dotknięcie, które robi to samo, albo treść widoczną od początku.

TL;DR

  • Ruch na hover zamykaj w (hover: hover) and (pointer: fine). :active i :focus-visible trzymaj poza tym zapytaniem, żeby dotyk i klawiatura dostały własną reakcję.
  • Hover może ozdabiać albo dawać podgląd. Nigdy nie jest jedyną drogą do etykiety, panelu czy wideo.
  • Granice wyznacza WCAG: 1.4.13 dla treści pokazywanej po najechaniu lub fokusie, 2.5.3 dla etykiet, które widać i da się wypowiedzieć, 2.5.8 dla celów, w które trafi kciuk.

Skąd się wzięła ta zasada?

Z mojego przeglądu makiet tej strony 5 października: telefony nie mają kursora, więc każdy efekt hover potrzebuje odpowiednika. Dziś to linijka w pliku z lekcjami projektu i w tabeli „rób / nie rób” systemu projektowego. Resztę tamtego przeglądu opisałem w studium przypadku tej strony.

Odpowiednik daje tę samą informację albo reakcję bez kursora nad elementem. Ta strona używa czterech rodzajów: wciśnięcia, stylu fokusu, dotknięcia, które robi to, co hover, i przewinięcia czegoś w pole widzenia.

Co telefon robi z :hover?

MDN mówi wprost, że :hover na ekranach dotykowych sprawia problemy. Zależnie od przeglądarki może nie pasować nigdy, pasować przez chwilę po dotknięciu albo pasować dalej, aż dotkniesz czegoś innego. Ten ostatni przypadek widać najbardziej: karta zostaje uniesiona, kiedy w nią stukniesz i do niej wrócisz.

CSS potrafi zapytać o sposób obsługi, zamiast zgadywać po szerokości ekranu:

Cecha O co pyta (według MDN) Przykładowe wartości
hover Czy główne urządzenie wejścia wygodnie obsługuje najechanie? none: wiele telefonów tylko udaje hover długim przytrzymaniem
pointer Czy jest urządzenie wskazujące i jak precyzyjne jest to główne? fine: mysz; coarse: palec
any-hover, any-pointer To samo dla dowolnego dostępnego urządzenia przydatne, gdy liczy się drugie urządzenie

Korzystam z wersji dla głównego urządzenia: przy dekoracji liczy się to, jak strona jest najpewniej używana w tej chwili.

Jak hover jest odgrodzony na tej stronie?

Przyciski pokazują cały wzorzec. Główny przycisk spoczywa na twardym cieniu 4 px. Pod myszą unosi się, a cień rośnie do 7 px. Wciśnij go, myszą albo palcem, a zapadnie się w swój cień.

@media (hover: hover) and (pointer: fine) {
  .btn:hover {
    transform: translate(-3px, -3px);
  }
  .btn-acc:hover,
  .btn-inv:hover {
    box-shadow: 7px 7px 0 var(--btn-shadow);
  }
}
/* Poza zapytaniem: dotyk też to dostaje. */
.btn:is(.btn-acc, .btn-inv):active {
  transform: translate(4px, 4px);
}
:focus-visible {
  outline: 3px solid currentColor;
  outline-offset: 3px;
}

Trzy stany, trzy grupy odbiorców: uniesienie dla myszy, wciśnięcie dla każdego, obwódka dla klawiatury. Przy ograniczonym ruchu wszystkie kończą się od razu, o czym więcej we wpisie o ruchu i przycisku pauzy.

Co liczy się jako odpowiednik?

Efekty hover na stronie głównej i to, co je zastępuje:

Efekt hover Gdzie Odpowiednik
Przycisk się unosi, cień rośnie każdy przycisk wciśnięcie przy dotknięciu; obwódka fokusu
Wiersz otwiera panel ze szczegółami Kompetencje otwiera go dotknięcie albo fokus; na telefonie akordeon
Krok opowieści się podświetla sekcja o AI dotknięcie odtwarza ten krok; obwódka fokusu
Przystanek na pierścieniu rośnie Jak pracuję dotknięcie pokazuje przystanek i trzyma go 9 s
Karteczki z wpisami prostują się i unoszą sekcja z wpisami to samo uniesienie, gdy link karty ma fokus z klawiatury
Flaga się przechyla, rysuje się podkreślenie przełącznik języka podkreślenie przy fokusie i przy bieżącym języku
Kafelek „Skopiuj e-mail” odwraca kolory kontakt dotknięcie kopiuje adres, komunikat to potwierdza

Kafelek „Skopiuj e-mail” pokazuje sam adres od początku, a jeśli przeglądarka odmówi dostępu do schowka, komunikat wyświetli adres zamiast potwierdzenia.

Dwa odpowiedniki na telefonie:

Sekcja Kompetencje na telefonie: stuknięty wiersz z egzaminami zawodowymi otwarty jak akordeon, z rysunkiem arkuszy Dotyk otwiera wiersz
Rozdział o portfolio w kosmosie na telefonie: główny przycisk mówi „Tylko na komputer · skopiuj link” Dotyk kopiuje link
Chromium z emulacją dotyku (pointer: coarse, hover: none), 8 października 2026. Mysz dostaje zamiast tego listę z panelem obok i przycisk „Uruchom na komputerze”.

Drugi w ogóle nie dotyczy stylów. Moje stare portfolio 3D nie ma nawigacji dotykiem, więc rozdział o nim podmienia główny przycisk przy (pointer: coarse). Mysz dostaje „Uruchom na komputerze”. Palec dostaje „Tylko na komputer · skopiuj link”, co kopiuje adres i podpowiada, żeby otworzyć go na komputerze. Ta sama intencja, uczciwie wobec urządzenia.

Przewijanie to czwarty odpowiednik. Wiele portfolio odtwarza podgląd projektu, kiedy kursor na nim spocznie. Tu wideo projektu gra, gdy jest widoczne co najmniej w połowie (po Twojej pierwszej interakcji ze stroną), staje poza ekranem, a dotknięcie kadru je wstrzymuje. Podgląd dostajesz, przewijając, a to i tak robisz na telefonie.

Jeden hover celowo nie ma odpowiednika. W pasku z narzędziami nazwa narzędzia robi się różowa pod myszą. Nazwy są widoczne zawsze, a pasek nie jest kontrolką, więc na telefonie nic nie ginie.

Jak jeden wiersz otwiera się na hover, fokus i dotknięcie?

Najwięcej dzieje się w Kompetencjach. Powyżej 900 px to lista ze szczegółami otwartego wiersza obok, przy czym jeden wiersz jest zawsze otwarty. Poniżej robi się z tego akordeon. Każdy wiersz to prawdziwy <button> z aria-expanded, a bez JavaScriptu wszystkie wiersze są po prostu otwarte.

Hover
Mysz przesuwa się w dół listy w oknie szerokim na 1200 px (kursora nie widać w nagraniu). Każdy wiersz otwiera się, gdy mysz nad nim przejeżdża, i nic się nie zamyka, gdy z niego zjedzie.

Skrypt, przycięty do tego, co ważne:

const wide = matchMedia("(min-width: 56.3125rem)");
const finePointer = matchMedia("(hover: hover) and (pointer: fine)");

for (const card of cards) {
  const button = card.querySelector<HTMLButtonElement>(".xc-btn")!;
  button.addEventListener("click", () => open(card, true));
  button.addEventListener("focus", () => wide.matches && open(card));
  card.addEventListener("pointerenter", () => wide.matches && finePointer.matches && open(card));
}

Dotknięcia obsługuje click i to jedyna ścieżka, która działa wszędzie. Na szerokim ekranie fokus otwiera wiersz, więc przechodzenie Tabem po liście przypomina przesuwanie po niej kursorem. Warunek przy pointerenter ma znaczenie: MDN zaznacza, że to zdarzenie odpala się także po pointerdown z urządzenia, które nie obsługuje najechania. Na ekranie dotykowym taki „hover” to tak naprawdę początek stuknięcia.

Gdzie jest miejsce na fokus z klawiatury?

:focus-visible pasuje wtedy, gdy przeglądarka uzna na podstawie heurystyk, że fokus trzeba pokazać. Przykład z MDN: przycisk kliknięty myszą zwykle nie pokazuje obwódki, a fokus z klawiatury tak.

W linkach łączę hover i fokus w jednym selektorze, żeby nie mogły się rozjechać:

.sm-nav a:is(:hover, :focus-visible) {
  font-stretch: 110%;
  color: var(--accent);
}

Linki w menu poszerzają się na osi szerokości fontu, tej samej, która stoi za dopasowanymi nagłówkami strony.

Karty wymagają jeszcze jednego kroku. Link w tytule karteczki rozciąga się na całą kartę, więc fokus trafia na mały element w środku dużego. :has() pozwala karcie zareagować na własny link:

.card.compact:has(.title a:focus-visible) {
  transform: translate(-3px, -3px);
  box-shadow: 11px 11px 0 var(--navy);
}

Które kryteria WCAG tu obowiązują?

1.4.13 Content on Hover or Focus (poziom AA) dotyczy dodatkowej treści, która pojawia się po najechaniu albo fokusie. Musi dać się ją zamknąć, najechać na nią bez jej znikania i musi zostać, dopóki jej nie opuścisz albo nie zamkniesz. Tutaj dotyczy to panelu Kompetencji. Panel nie zamyka się, gdy kursor go opuści, i stoi we własnej kolumnie, więc lista pozostaje czytelna. Czy podmiana panelu to „zastąpienie treści”, jest kwestią interpretacji; traktuję wiersze jak zakładki.

Kryterium wyłącza dymki, które przeglądarka rysuje z atrybutu title. To nie znaczy, że są dobrym pomysłem: MDN nazywa title wysoce problematycznym dla osób z samym ekranem dotykowym, z klawiaturą i z czytnikiem ekranu. Flagi w przełączniku języka mają title, ale nic od niego nie zależy. Każdy link z flagą niesie nazwę języka jako tekst dla czytnika ekranu, a menu pokazuje nazwy obok flag.

2.5.3 Label in Name (poziom A) wymaga, żeby dostępna nazwa kontrolki zawierała widoczny tekst, najlepiej na początku. Chodzi o sterowanie głosem: ludzie wypowiadają etykietę, którą widzą. Etykiety pokazywanej tylko na hover osoba z telefonem nigdy nie zobaczy, więc każda etykieta jest tu widoczna od razu. Przycisk CV mówi „CV”, a dalej, tylko dla czytników ekranu, „(PDF, potem strona CV)”.

2.5.8 Target Size (Minimum) (poziom AA) wymaga 24 na 24 piksele CSS albo wystarczających odstępów. Celuję wyżej: przyciski mają 46 px wysokości (40 px w przyklejonym nagłówku), a linki z flagami 44 px.

Jak to sprawdzam?

Głównie czytając kod. Szukam w źródłach :hover i sortuję wyniki. Uniesienia, cienie i wszystko, co się rusza, powinno siedzieć w zapytaniu. Zwykłe style linków (podkreślenie, kolor) mogą zostać poza nim: jeśli taki styl się przyklei, dostajesz kolorowy link, a nie rozjechany układ.

Sortowanie 6 października, przy pisaniu pierwszej wersji tego wpisu, wyłapało cztery drobne ruchy bez tej bramki. Były to bilety „Wypuściłem też” pod rozdziałami projektów, przechylenie flagi i dwa elementy strony „Moje życie”. Wszystkie miały styl fokusu, ale na telefonie bilet mógł zostać uniesiony po dotknięciu. Tego samego dnia każdy z tych ruchów trafił za (hover: hover), a styl fokusu został dla wszystkich.

Ograniczenia

  • Żaden automatyczny test nie sprawdza hovera. Testy end-to-end działają na profilu desktopowego Chrome'a, więc odpowiedniki sprawdzam, czytając kod i ręcznie.
  • Zdjęcia z telefonu pochodzą z emulacji dotyku w Chromium, a stanu wciśnięcia nie sprawdziłem na prawdziwym iPhonie (testy WebKita puszczam na Windowsie).
  • Ocena panelu Kompetencji względem 1.4.13 to moja lektura kryterium, a nie audyt.

Jeśli czytasz to na telefonie, najszybszy test to sekcja Kompetencje na stronie głównej: stuknij wiersz, a otworzy się pod Twoim kciukiem, bez kursora.

Źródła

  1. :hover (MDN) (otwiera się w nowej karcie) (dostęp: )
  2. hover media feature (MDN) (otwiera się w nowej karcie) (dostęp: )
  3. pointer media feature (MDN) (otwiera się w nowej karcie) (dostęp: )
  4. any-hover media feature (MDN) (otwiera się w nowej karcie) (dostęp: )
  5. :focus-visible (MDN) (otwiera się w nowej karcie) (dostęp: )
  6. :has() (MDN) (otwiera się w nowej karcie) (dostęp: )
  7. Element: pointerenter event (MDN) (otwiera się w nowej karcie) (dostęp: )
  8. title global attribute (MDN) (otwiera się w nowej karcie) (dostęp: )
  9. Understanding SC 1.4.13: Content on Hover or Focus (W3C WAI, WCAG 2.2) (otwiera się w nowej karcie) (dostęp: )
  10. Understanding SC 2.5.3: Label in Name (W3C WAI, WCAG 2.2) (otwiera się w nowej karcie) (dostęp: )
  11. Understanding SC 2.5.8: Target Size (Minimum) (W3C WAI, WCAG 2.2) (otwiera się w nowej karcie) (dostęp: )

Wszystkie wpisy