Влияние CSS на доступность

Разбираемся, какие CSS-свойства влияют на доступность сайтов для пользователей скринридеров и других вспомогательных технологий.

Время чтения: 14 мин

Кратко

Секция статьи "Кратко"

Мы привыкли, что в структуре сайта CSS отвечает только за визуальное представление, а всё касающееся контента задаётся в HTML. Однако, это не совсем так. В CSS есть свойства, способные как улучшить восприятие вашего сайта для пользователя вспомогательных технологий, так и сильно усложнить ему жизнь.

В статье разберёмся, что это за свойства и почему так происходит.

Важные уточнения

Секция статьи "Важные уточнения"

Большая часть материала в статье посвящена влиянию CSS на скринридеры.

В Доке есть отдельная статья про скринридеры. Здесь я только кратко процитирую, что такое скринридер.

Скринридер (screen reader) — программа, которая превращает контент интерфейсов в речь или шрифт Брайля.

Скринридеры нужны людям со слепотой и слабовидящим, а также пользователям с когнитивными особенностями, которым легче воспринимать информацию на слух. Например, людям с дислексией.

Также в тексте статьи много демок. Можете протестировать их сами, чтобы не верить мне на слово. Для этого нужно установить или включить скринридер — в статье про скринридеры есть список существующих программ для каждой операционной системы. Инструкции по скачиванию и подключению обычно есть на сайтах скринридеров.

Наконец, все демки я протестировала со скринридером NVDA в Windows в Google Chrome. Если тестируете с другим скринридером, его поведение может немного отличаться от описанного в статье.

Об основном договорились, теперь можно двигаться дальше 🙂

Списки

Секция статьи "Списки"

Чтобы не рассматривать скучные абстрактные примеры, давайте представим, что нам нужно пойти в магазин, купить кучу всего и ничего не забыть.

В этом нам поможет приложение для покупок — именно его мы увидим во всех демках.

Первое, что нужно сделать — составить список покупок.

Допустим, в этом случае нам не важно, сколько пунктов в списке, поэтому мы используем ненумерованный список — <ul>.

Открыть демо в новой вкладке

Свойство list-style: none

Секция статьи "Свойство list-style: none"

Первое, что мы обычно делаем с ненумерованным списком, — убираем стандартные буллиты с помощью свойства list-style:none. Кажется, что на скринридер такое изменение влиять не должно, ведь оно касается только визуального представления списка. Однако на практике это не так.

У обычного списка с буллитами NVDA сначала озвучивает количество элементов в списке, а затем перед каждым элементом произносит слово «маркер». Это даёт пользователю понять, что он перешёл к следующему пункту списка. Например:

Список из четырёх элементов. Маркер. Апельсины. Маркер. Хлеб…

У списка с list-style: none количество элементов произносится, но слово «маркер» опускается. Элементы списка просто зачитываются подряд:

Список из четырёх элементов. Апельсины. Хлеб…

В случае со скринридером VoiceOver свойство list-style: none приведёт к ещё большей путанице. В Safari список с list-style: none вовсе не озвучивается как список. Мы не услышим ни количество элементов, ни слово «маркер».

Если список нумерованный (<ol>), то для каждого пункта списка скринридер зачитывает порядковый номер, например:

Один. Апельсины. Два. Хлеб.

В случае с list-style: none этот номер игнорируется.

Кастомные маркеры

Секция статьи "Кастомные маркеры"

Получается, что совсем без маркера оставлять список как-то нехорошо. Чаще всего для списков верстают кастомные маркеры. Рассмотрим два распространённых способа это сделать.

Псевдоэлемент ::before

Секция статьи "Псевдоэлемент ::before"

Я хочу, чтобы список выглядел повеселее, поэтому вместо стандартного маркера вставим эмодзи канцелярской кнопки — 📌.

Это можно сделать с помощью псевдоэлемента ::before у элемента списка <li>:

        
          
          .list_emoji li::before {  content: '📌';  display: block;  position: absolute;  top: -3px;  left: -22px;}
          .list_emoji li::before {
  content: '📌';
  display: block;
  position: absolute;
  top: -3px;
  left: -22px;
}

        
        
          
        
      

В свойстве content у псевдоэлемента указано его содержимое (эмодзи кнопки), а остальные свойства помогают спозиционировать ::before относительно элемента списка.

Посмотрим на получившийся «нарядный» список. В демке он под заголовком «::before».

Открыть демо в новой вкладке

А как это звучит?

Скринридер озвучивает содержимое свойства content, поэтому перед каждым пунктом списка мы слышим название эмодзи:

Канцелярская кнопка. Апельсины. Канцелярская кнопка. Хлеб…

content — ещё одно CSS-свойство, влияющее на поведение скринридера. Его содержимое почти всегда будет зачитано, если скринридер найдёт там что-то читабельное. Это может быть текст, цифры или эмодзи.

К счастью, громоздкую конструкцию, например, с адресом ссылки на картинку, NVDA не зачитает 🙂 В этом примере скринридер пропустит значение свойства content и не будет его читать:

        
          
          .local-link::before {  content: url("/media/examples/firefox-logo.svg");}
          .local-link::before {
  content: url("/media/examples/firefox-logo.svg");
}

        
        
          
        
      

Псевдоэлемент ::marker

Секция статьи "Псевдоэлемент ::marker"

Ещё один вариант создания кастомных маркеров — псевдоэлемент ::marker.

Снова глянем на демку — теперь нас интересует список с маркерами-галочками.

Открыть демо в новой вкладке

В этом случае NVDA не будет зачитывать содержимое псевдоэлемента и опустит слово «маркер» перед элементом списка. То есть, как и в примере с list-style: none без кастомных маркеров, снова получим:

Список из 4 элементов. Апельсины. Хлеб…

Значит, псевдоэлемент ::marker влияет только на внешний вид списка.

Бонусный фан-факт

Секция статьи "Бонусный фан-факт"

В этой демке есть ещё один интересный элемент — кнопка. Она выглядит совершенно обычно, но нам важно, что все буквы здесь капитализированы с помощью свойства text-transform: uppercase.

        
          
          .button-uppercase {  text-transform: uppercase;}
          .button-uppercase {
  text-transform: uppercase;
}

        
        
          
        
      

Оказывается, раньше VoiceOver читал капитализированный текст по буквам. Из нашей кнопки получилось бы «Д.О.Б.А.В.И.Т.Ь.П.У.Н.К.Т.» 😱

Одно время в сообществе кипели обсуждения баг это или нет, но в какой-то момент такое поведение всё-таки признали багом и исправили. Сейчас с таким столкнуться уже невозможно.

Свойство order

Секция статьи "Свойство order"

Список собрали и теперь самое время отправляться за покупками. Допустим, в нашем магазине карточки товаров выглядят как кнопки с эмодзи и текстами.

Открыть демо в новой вкладке

В демке обычный блок с display: flex. Попробуем озвучить весь список товаров скринридером.

Ожидание:

Апельсины. Молоко. Воздушный змей. Сок. Брецель. Яблоки.

Реальность:

Апельсины. Яблоки. Молоко. Сок. Брецель. Воздушный змей.

Кажется, что скринридер хаотично изменил порядок товаров. Даже без озвучки при прохождении списка с помощью клавиатуры видно, что фокус как будто прыгает по случайным элементам. На самом деле это не так.

Если заглянуть в код демки, мы увидим, что элементы в HTML стоят ровно в том порядке, в каком их читает скринридер. При этом каждому из них задано свойство order. Как раз оно и определяет их визуальный порядок.

display: table и display: grid

Секция статьи "display: table и display: grid"

Итак, мы закупились в магазине согласно нашего списка и получили чек с перечнем купленного.

Открыть демо в новой вкладке

Первый вариант чека — стандартная таблица, свёрстанная с помощью тега <table>. Она выглядит как таблица и крякает читается скринридером как таблица.

При чтении этой таблицы скринридер честно расскажет нам, сколько в ней строк и столбцов:

Таблица из 4 строк и 3 столбцов.

Также для каждой ячейки будет уточнять название и номер столбца, в котором она находится:

Продукт. Столбец 1. Апельсины. Количество. Столбец 2. 1 килограмм.

А ещё при переходе на новую строку озвучит номер строки:

Строка 3. Продукт. Столбец 1. Молоко.

Скринридеры прекрасно работают с таблицами, поэтому здесь никаких вопросов — всё читается так, как мы ожидали. Но верстать таблицы мало кто любит. А что, если сверстать то же самое, но с помощью display: grid?

Посмотрим на второй вариант чека. Визуально всё выглядит почти в точности так же, как и обычная таблица. Но внутри теперь не семантический тег <table>, а обычные <div> и <p>. Ожидаемо такой компонент не будет читаться как таблица. В итоге мы услышим простое озвучивание контента блоков:

Продукт. Количество. Цена. Апельсины. 1 килограмм.

Окей, у таблицы есть свойство display: table. Может, всё дело в нём? Попробуем сверстать псевдо-таблицу с помощью обычных <div>, но зададим блоку-обёртке display: table. В демке это первый вариант с заголовком «Таблица и display: table».

Открыть демо в новой вкладке

Увы, скринридер всё ещё не считает это таблицей и снова читает только контент внутри блоков в ожидаемом нами порядке:

Продукт. Количество. Цена. Апельсины. 1 килограмм.

Продолжим экспериментировать и теперь навесим свойство display: grid на настоящую семантическую таблицу. Зачем? Во-первых, почему бы и нет. А во-вторых, чтобы проверить утверждение, которое встречалось мне в нескольких статьях. В этом случае свойство display: grid должно сломать семантику таблицы.

Проверим на практике. Вторая таблица с заголовком «Контейнеры и display: grid» из демки свёрстана как таблица с display: grid у тега <table>.

На удивление, NVDA справился с такой путаницей отлично. Он прочитал элемент как настоящую таблицу и озвучил все строки и столбцы. Однако стоит помнить, что другие скринридеры могут повести себя в такой ситуации совершенно непредсказуемо. Это значит, что таких сюжетных поворотов в вёрстке лучше избегать 🙂

Как спрятать содержимое?

Секция статьи "Как спрятать содержимое?"

Есть много способов скрыть контент страницы, но не все из них скрывают контент одновременно и для зрячих пользователей, и для пользователей скринридеров.

В Доке есть отдельная статья «Как скрыть содержимое от скринридеров». В ней подробно описаны все способы скрытия и показа содержимого. Например, только визуально, только для скринридеров или всё вместе. Поэтому здесь я только кратко процитирую описание CSS-свойств, которые заставят скринридер замолчать 😈

  • width: 0px и height: 0px удаляют элементы из потока страницы, поэтому скринридеры их не прочитают. Не работает с NVDA. Он по-прежнему будет читать такие элементы.
  • visibility: hidden скрывает содержимое тега, но оставляет элемент в обычном потоке страницы таким образом, что он по-прежнему занимает место.
  • display: none полностью удаляет элемент из документа. Он не занимает места, хотя всё ещё находится в исходном HTML-коде.

Не используйте эти CSS-стили, если хотите, чтобы содержимое читалось программой чтения с экрана.

Анимации и prefers-reduced-motion

Секция статьи "Анимации и prefers-reduced-motion"

Ура, кажется, мы всё купили и теперь можем посмотреть на красивый экран с анимацией 🎉

Открыть демо в новой вкладке

Однако, на доступном сайте должна быть возможность отключить анимацию, если пользователю это важно. Такое поведение описано в одном из требований WCAG 2 (Web Content Accessibility Guidelines 2)2.3.3. Анимация при взаимодействии.

У CSS-директивы @media есть значение, которое позволяет влиять на анимации в зависимости от настроек системы пользователя — prefers-reduced-motion. Если в операционной системе пользователя отключена анимация, то на сайте будет выполнен CSS-код внутри директивы.

Например, в этом коде анимация в prefers-reduced-motion выключена совсем:

        
          
          .animated-element {  animation: rotation 1.6s infinite;}@media (prefers-reduced-motion) {  .animated-element {    animation: none;  }}
          .animated-element {
  animation: rotation 1.6s infinite;
}

@media (prefers-reduced-motion) {
  .animated-element {
    animation: none;
  }
}

        
        
          
        
      

В демке уже написаны стили, отключающие анимацию при prefers-reduced-motion. Это можно проверить, отключив отображение анимации у себя на компьютере или смартфоне в настройках системы.

  • Для Windows: Параметры → Специальные возможности → Другие параметры → Воспроизводить анимацию в Windows.
  • Для macOS: Системные настройки → Универсальный доступ → Монитор → Уменьшить движение.
  • Для Linux: Настройки → Специальные возможности → Разрешить анимацию.
  • Для iOS: Настройки → Универсальный доступ → Движение → Уменьшение движения.
  • Для Android: Настройки → Специальные возможности → Экран → Удалить анимации.

Если ваш браузер — Google Chrome, то можно имитировать эту настройку в инструменте разработчика: «Другие инструменты» (More tools) → вкладка «Отрисовка» (Rendering) → «Эмулировать медиафункцию CSS prefers-reduce-motion» (Emulate CSS media feature prefers-reduce-motion).

При активированном режиме «уменьшенной анимации» хлопушка в демке не должна двигаться. При отключении этого режима она начнёт двигаться снова.

Кстати, в Доке уже есть материал о prefers-reduced-motion.

Почему стили влияют на доступность?

Секция статьи "Почему стили влияют на доступность?"

Как скринридер решает, какие элементы он будет читать, а какие пропустит?

Дело в том, что скринридер читает не просто контент страницы или её разметку, а общается с браузером при помощи Accessibility API (Accessibility Application Programming Interface).

Accessibility API передаёт скринридеру данные о странице в виде дерева доступности (acessibility tree). Оно похоже на DOM-дерево, но состоит из доступных объектов (accessible object). То есть, именно Accessibility API превращает разметку страницы в «сценарий чтения». Как этот сценарий будет выглядеть зависит от многих факторов. Например, от семантической разметки и используемых CSS-свойств.

Более подробно про дерево доступности можно почитать в статье о скринридерах.

Выводы

Секция статьи "Выводы"

CSS тоже может влиять на то, как контент страницы будет прочитан скринридером.

  • Свойство list-style: none превращает семантический список в обычный перечень элементов. Скринридер не скажет сколько элементов в списке и не будет обозначать каждый новый элемент словом «маркер» в случае с <ul> или порядковым номером элемента в случае с <ol>.
  • Содержимое псевдоэлементов ::before и ::after зачитывается скринридером. Если внутри свойства content ссылка (например, на картинку), он её не прочитает.
  • Содержимое псевдоэлемента ::marker невидимо для скринридера, но список с таким псевдоэлементом всё равно не читается как список, если ему задан list-style: none.
  • Свойство order меняет порядок элементов только визуально. Скринридер будет читать элементы в том порядке, в котором они расположены в разметке.
  • display: table не сделает для скринридера таблицу из обычных <div>-контейнеров. display: grid у <table> вовсе может сломать всю семантику для некоторых скринридеров.
  • display: none и visibility: hidden позволяют скрыть контент как визуально, так и от скринридеров. width: 0px и height: 0px тоже скрывают контент, но не для всех скринридеров. Например, NVDA прочитает блок, спрятанный таким образом.
  • С помощью директивы @media со значением prefers-reduced-motion можно предоставить фолбэк-стили на случай, если у пользователя в системе выключена анимация.

Сложно заранее представить, как скринридер озвучит интерфейс, даже если прочитали кучу статей о вспомогательных технологиях и, кажется, знаете уже все подводные камни. Единственный способ проверить как действительно звучит содержимое — сесть и протестировать его с любым скринридером вручную. Это всегда полезно, а часто ещё и довольно весело.

Узнать больше об особенностях влияния CSS на скринридеры можно в этих статьях: