Визуальная доступность: контраст, размер, масштабирование, анимация
Предыдущие статьи трека были про людей, которые получают интерфейс не глазами: через семантику и дерево доступности, через клавиатуру, через скринридер. Эта статья — про самую многочисленную группу: людей, которые смотрят на экран, но видят его иначе, чем вы за своим ноутбуком.
Это важная поправка к интуиции. Разработчик обычно думает «доступность = скринридер» и делает вывод, что тема касается редких сценариев. На деле большинство визуальных барьеров устроено иначе: человек видит ваш интерфейс, просто серый текст на светло-сером фоне для него не читается, кнопка 16×16 не попадается пальцем, а на масштабе 200% половина формы уезжает за край. Такой пользователь почти никогда не напишет в поддержку — он просто уйдёт. По оценке ВОЗ, не менее 2,2 млрд человек живут с той или иной формой нарушения зрения — от возрастной дальнозоркости до глаукомы; подавляющее большинство из них никаким скринридером не пользуется. К ним добавляются те, у кого зрение обычное, а условия — нет: солнце на экране телефона, дешёвая матрица с заваленными полутонами, усталость к концу рабочего дня. Требования этого раздела WCAG работают на всех сразу. Базовый свод правил уже есть в Гайде по accessibility, карта трека — в обзоре; повторять их не будем. Здесь разбираемся, откуда берутся числа, что именно ломается в реальном коде и как проверить свою страницу руками за пять минут.
1. Карта визуальных барьеров
Полезно держать в голове не список диагнозов, а список способов восприятия: каждый даёт свой класс дефектов.
Практический вывод: визуальная доступность — это не «сделать покрупнее», а пять независимых осей: контраст, независимость от цвета, масштабируемость, размер целей, поведение движения. Каждую проверяют отдельно — они ломаются независимо друг от друга.
2. Контраст: откуда берутся 4,5:1
Как считается коэффициент контрастности
WCAG 2.x определяет контраст через относительную яркость (relative luminance) — величину от 0 (чёрный) до 1 (белый). Считается она в три шага.
Шаг 1. Нормализация. Каждый канал 8-битного цвета делим на 255, получая $R, G, B$ в диапазоне от 0 до 1. Шаг 2. Линеаризация. sRGB хранит цвет нелинейно — так, чтобы шаги между близкими тёмными оттенками были заметнее глазу; для расчёта нужна линейная шкала, поэтому гамма-коррекцию снимают: если $c \le 0.04045$, то $c_{lin} = c / 12.92$; иначе $c_{lin} = ((c + 0.055) / 1.055)^{2.4}$.
Шаг 3. Взвешенная сумма. Глаз заметно чувствительнее к зелёному, чем к синему, поэтому каналы складываются с разными весами:
$$L = 0.2126 R_{lin} + 0.7152 G_{lin} + 0.0722 B_{lin}$$
Коэффициент контрастности двух цветов — отношение их яркостей со сдвигом 0.05, моделирующим паразитную засветку экрана:
$$C = \frac{L_1 + 0.05}{L_2 + 0.05}$$
где $L_1$ — яркость более светлого цвета. Отсюда диапазон: от $1{:}1$ (цвета совпадают) до $21{:}1$ (чёрное на белом). Из весов сразу следует нетривиальное: чистый синий #0000ff на белом даёт 8,6:1 и проходит даже AAA, а чистый жёлтый #ffff00 на белом — 1,07:1, то есть практически невидим. Интуиция «яркий цвет = заметный цвет» здесь не работает вообще.
Пороговые значения WCAG 2.2
| Критерий | Уровень | Что требует |
|---|---|---|
| 1.4.3 Contrast (Minimum) | AA | 4,5:1 для обычного текста; 3:1 для крупного |
| 1.4.6 Contrast (Enhanced) | AAA | 7:1 для обычного текста; 4,5:1 для крупного |
| 1.4.11 Non-text Contrast | AA | 3:1 для границ контролов, состояний и значимых графических объектов |
Крупный текст — от 18pt (24 CSS px) обычного начертания либо от 14pt (примерно 18,66 px) полужирного. Не «на глаз крупный», а строго по этим числам.
Разбор критериев — Understanding 1.4.3 и Understanding 1.4.11. Критерий 1.4.11 обычно недооценивают, а он закрывает половину типовых проблем интерфейса:
- граница поля ввода должна быть отличима от фона на 3:1 — светло-серая рамка
#e0e0e0на белом даёт 1,2:1, то есть поля визуально не существует; - иконка, несущая смысл (стрелка «свернуть», значок ошибки), — 3:1 к своему фону;
- индикатор фокуса — 3:1 к соседним цветам (см. клавиатуру и фокус);
- состояния переключателя: включённый и выключенный тумблер должны различаться не только оттенком.
Чисто декоративная графика под 1.4.11 не попадает, но «декоративная» означает «удалите её, и ничего не изменится», а не «мне кажется, она не очень важная».
Считаем контраст в коде
"""Контраст по WCAG 2.x: относительная яркость и коэффициент контрастности."""
def _linearize(c: float) -> float:
"""Снимаем гамма-коррекцию sRGB — переходим к линейной яркости канала."""
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
def luminance(hex_color: str) -> float:
"""Относительная яркость: 0 — чёрный, 1 — белый."""
v = hex_color.lstrip("#")
v = "".join(ch * 2 for ch in v) if len(v) == 3 else v # короткая запись #abc
r, g, b = (int(v[i:i + 2], 16) / 255 for i in (0, 2, 4))
return 0.2126 * _linearize(r) + 0.7152 * _linearize(g) + 0.0722 * _linearize(b)
def contrast(fg: str, bg: str) -> float:
"""Коэффициент контрастности пары: от 1.0 (совпадают) до 21.0 (чёрное на белом)."""
l1, l2 = luminance(fg), luminance(bg)
return (max(l1, l2) + 0.05) / (min(l1, l2) + 0.05)
# Пороги AA: 4.5 обычный текст, 3.0 крупный; AAA: 7.0 и 4.5 соответственно
print(round(contrast("#9aa5b1", "#ffffff"), 2)) # 2.55 — типовой «muted» серый, провал
print(round(contrast("#767676", "#ffffff"), 2)) # 4.54 — минимально проходящий на белом
print(round(contrast("#ffff00", "#ffffff"), 2)) # 1.07 — жёлтый практически невидим
Сложность: одна пара — $O(1)$ по времени и памяти; полная проверка палитры из $N$ цветов текста на $M$ фонах — $O(NM)$, что для десятков токенов считается мгновенно и отлично живёт в CI. Именно поэтому контраст — единственный «визуальный» критерий, который автоматика ловит почти без ложных срабатываний: это арифметика над двумя числами.
Где автоматика ошибается
Формула требует двух плоских цветов. Как только фон перестаёт быть плоским, ни axe, ни Lighthouse не дадут честного ответа: текст поверх фотографии или градиента даёт incomplete («needs review»); полупрозрачный текст нужно считать после наложения всех слоёв, и не все инструменты делают это правильно; текст поверх видео не проверяется вовсе. Лечение одно: не полагайтесь на удачу — подложите гарантированный фон. Не text-shadow (он помогает глазу, но не даёт детерминированного результата и работает не на всех кадрах), а скрим или плашку.
/* Плохо: контраст зависит от того, какой кадр фотографии оказался под буквами */
.hero__title { color: #fff; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); }
/* Хорошо: под текстом гарантированный слой, контраст детерминирован при любом кадре */
.hero__scrim { background: linear-gradient(to top, rgba(12,16,22,.88), rgba(12,16,22,.6)); }
.hero__title { color: #ffffff; } /* на #1a222c это 14.6:1 */
Отдельно про disabled и плейсхолдеры
Критерий 1.4.3 делает исключение для неактивных контролов. Исключение это юридическое, а не человеческое: если текст на серой кнопке нечитаем, человек не понимает, какое действие недоступно и почему. Практическое правило — держать disabled не ниже 3:1 и никогда не передавать недоступность только цветом, добавляя текстовое объяснение рядом («Заполните адрес, чтобы продолжить»). Плейсхолдер под исключение не попадает вовсе: это обычный текст, к нему применяется 4,5:1. И он не заменяет <label> — разбор в следующей статье про формы.
Тёмная тема, пределы формулы и APCA
Скажем честно про слабое место WCAG 2.x: формула опирается на измерения двадцатилетней давности и на светлом фоне работает заметно лучше, чем на тёмном. Следствия, которые видно на практике:
- белый текст на чистом чёрном формально даёт максимальные 21:1, но на OLED вызывает halation — расплывание букв, особенно заметное при астигматизме. Практика:
#e6e8ebна#12161cвместо#ffffffна#000000; - тонкие начертания (weight 200–300) при том же коэффициенте читаются хуже полужирных: толщина штриха в формуле не учитывается вообще;
- насыщенные цвета одинаковой яркости формула считает эквивалентными, хотя воспринимаются они по-разному.
Кандидат на замену в будущей WCAG 3 — APCA, где учитываются полярность (тёмное на светлом или наоборот), размер и насыщенность шрифта, а результат выражается в единицах Lc (ориентиры: Lc 60 для основного текста, Lc 75 для мелкого, Lc 45 для крупных заголовков). Историю метрики полезно помнить целиком: WCAG 1.0 (1999) оперировала эвристической «разницей яркости и цвета», WCAG 2.0 (2008) ввела нынешний коэффициент, WCAG 2.1 (2018) распространила порог 3:1 на границы и иконки критерием 1.4.11, WCAG 2.2 (2023) формулу не трогала. Но APCA не нормативна ни в одном действующем стандарте. Рабочая стратегия: соответствовать WCAG 2.2 формально, а APCA использовать как дополнительный сигнал при проектировании тёмной темы.
3. Цвет не должен быть единственным каналом
Критерий 1.4.1 Use of Color — уровень A, то есть базовый — требует: цвет не может быть единственным способом передать информацию, обозначить действие или показать состояние. Кого это касается: примерно 8% мужчин и 0,5% женщин европейского происхождения имеют ту или иную особенность цветовосприятия (чаще всего затруднено различение красного и зелёного). Плюс все, кто смотрит на монохромный экран, распечатал страницу на чёрно-белом принтере или сидит под проектором с убитой цветопередачей.
информацию?"} B -- "Нет, чистая эстетика" --> Z["Требований нет,
контраст текста всё равно проверяем"] B -- "Да" --> C{"Есть второй канал:
текст, иконка, форма, узор?"} C -- "Нет" --> F["Нарушение 1.4.1, уровень A"] C -- "Да" --> D{"Второй канал доступен
без наведения курсора?"} D -- "Нет, только на hover" --> F D -- "Да" --> E{"Контраст самого различия
не ниже 3:1?"} E -- "Нет" --> G["Нарушение 1.4.11:
различие видно не всем"] E -- "Да" --> H["Требование выполнено"] style F fill:#f6d5d3,stroke:#c05f5a,color:#3a2422 style G fill:#f7e6c8,stroke:#b3862f,color:#3a3122 style H fill:#d6ecdf,stroke:#3f8a68,color:#22332b
Ссылка в абзаце, отличающаяся только цветом. Если подчёркивание убрано, ссылка обязана контрастировать с окружающим текстом минимум на 3:1 и получать дополнительный признак при наведении и фокусе. Практичнее оставить подчёркивание — самый узнаваемый признак ссылки за всю историю веба.
.prose a { /* #1a5fb4 — 6.4:1 к белому фону, 3.5:1 к тексту #333 */
color: #1a5fb4; text-decoration: underline;
text-underline-offset: .16em; text-decoration-thickness: .08em;
}
.prose a:hover, .prose a:focus-visible { text-decoration-thickness: .14em; }
Статус только цветом. Зелёная и красная точки в таблице заказов неразличимы для части пользователей и вдобавок молчат для скринридера.
<span class="dot dot--red"></span> <!-- плохо: смысл живёт в CSS-классе -->
<span class="status status--error"> <!-- хорошо: форма + иконка + текст -->
<svg width="16" height="16" aria-hidden="true" focusable="false"><use href="#icon-cross"/></svg>
Отклонён
</span>
График с легендой сбоку. Пять линий пяти цветов, которые нужно сопоставлять по оттенку, — классическое нарушение. Решения: подписи прямо у линий (direct labeling), разные типы штриха через stroke-dasharray, разные маркеры точек, SVG <pattern> для заливок столбцов. Обязательные поля только красной звёздочкой. Звёздочка — уже форма, формально допустимо; но пояснение «Поля со звёздочкой обязательны» в начале формы работает лучше. Проверка занимает секунды: Chrome DevTools → Rendering → Emulate vision deficiencies → Achromatopsia. Если в монохроме интерфейс остаётся понятным, критерий выполнен. Там же есть Protanopia, Deuteranopia, Tritanopia, Blurred vision и Reduced contrast; в Firefox то же самое — в панели Accessibility → Simulate.
4. Масштаб, reflow и текстовые интервалы
Три разных требования, которые постоянно путают
| Критерий | Уровень | Суть | Как проверяем |
|---|---|---|---|
| 1.4.4 Resize Text | AA | текст масштабируется до 200% без потери контента и функций | зум 200% в браузере |
| 1.4.10 Reflow | AA | контент читается в области 320 CSS px без двумерной прокрутки | зум 400% при окне 1280px |
| 1.4.12 Text Spacing | AA | ничего не ломается при увеличенных интервалах | инъекция CSS в DevTools |
Число 320 выбрано не случайно: это ширина вьюпорта самого узкого распространённого телефона и одновременно 1280 / 4. То есть ваш мобильный адаптив почти бесплатно закрывает требование reflow для десктопа. Формально критерий говорит про 320 CSS px по ширине и 256 CSS px по высоте для контента с горизонтальной прокруткой; подробности — Understanding 1.4.10.
px, rem и настройка размера шрифта
Механизмов увеличения два, и путать их — источник большинства ошибок.
Page zoom (Ctrl + «+») масштабирует всё: и px, и rem, и картинки, — единицы тут не важны. Настройка размера шрифта по умолчанию (Firefox: Настройки → Шрифты; Chrome: Внешний вид → Размер шрифта) меняет корневой размер — обычно 16px, но человек может поставить 20, 24 или 32. Здесь единицы решают всё: font-size: 14px останется четырнадцатью пикселями, а 0.875rem превратится в 21px при корневых 24px. Люди со слабым зрением очень часто настраивают именно это: настройка глобальная и работает на всех сайтах сразу.
.card__title { font-size: 14px; height: 40px; overflow: hidden; } /* плохо: настройка
проигнорирована, а фиксированная высота обрежет вторую строку при любом увеличении */
.card__title { font-size: .875rem; min-height: 2.5rem; line-height: 1.5; } /* хорошо */
html { font-size: 62.5%; } /* антипаттерн «чтобы 1rem = 10px»: режет пользовательскую
настройку на 37.5% и делает мелкий текст ещё мельче */
.hero { font-size: 5vw; } /* плохо: чистый vw не реагирует на зум как ожидает человек */
.hero { font-size: clamp(1.75rem, 1.2rem + 2.5vw, 3.5rem); } /* хорошо: минимум в rem */
Правило: в clamp() минимальное значение и слагаемое в середине задавайте в rem — тогда корректно отработают и зум, и пользовательский размер шрифта.
Запрет масштабирования на мобильных
<!-- Категорическое нет: отбирает пинч-зум -->
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<!-- Правильно -->
<meta name="viewport" content="width=device-width, initial-scale=1">
iOS Safari новых версий user-scalable=no игнорирует, Android — нет. Единственная причина, по которой это когда-то писали (автозум при фокусе в поле на iOS), лечится размером шрифта поля не меньше 16px.
Text Spacing: проверка, ломающая больше всего вёрстки
Критерий 1.4.12 требует, чтобы ничего не терялось и не переставало работать при межстрочном интервале от 1,5 размера шрифта, отступе после абзаца от 2 размеров, межбуквенном от 0,12em и межсловном от 0,16em. Ровно это настраивают пользовательские стили и расширения для дислексии. Проверяется одним правилом:
/* DevTools → Elements → «+» (new style rule) → вставить и посмотреть на страницу */
* { line-height: 1.5 !important; letter-spacing: .12em !important; word-spacing: .16em !important; }
p { margin-bottom: 2em !important; }
Что обычно ломается: текст в кнопках фиксированной высоты обрезается, бейджи наезжают друг на друга, overflow: hidden съедает вторую строку, однострочные подписи становятся двухстрочными и рушат сетку. Лечится отказом от фиксированных высот в пользу min-height плюс padding. Из смежного: критерий 1.3.4 Orientation запрещает жёстко фиксировать ориентацию (планшет человека в кресле-коляске часто закреплён в одном положении), а 1.4.5 Images of Text — текст картинкой, который не перерисуется шрифтом пользователя и замылится при увеличении.
5. Размер целей: критерий 2.5.8
WCAG 2.2 добавил 2.5.8 Target Size (Minimum) уровня AA: интерактивная область не меньше 24×24 CSS px. Уровень AAA (2.5.5) требует 44×44 — то же значение рекомендуют гайдлайны мобильных платформ.
Кому это нужно: людям с тремором и ограниченной моторикой, всем на сенсорном экране в транспорте, любому, кто пользуется телефоном одной рукой на ходу. Исключения, которые важно знать, чтобы не переделывать лишнее: spacing — цель меньше 24px допустима, если вокруг её центра вписывается круг диаметром 24px, не пересекающий такой же круг соседа; inline — ссылка внутри абзаца не считается; equivalent — то же действие доступно другим достаточно крупным элементом; essential — размер обусловлен существом задачи (точка на карте, кадр на таймлайне).
/* Расширяем зону нажатия, не меняя внешний вид иконки */
.icon-button {
position: relative; display: inline-grid; place-items: center; border: 0;
inline-size: 20px; block-size: 20px; background: none; cursor: pointer;
}
.icon-button::after {
content: ""; position: absolute; inset: 50% auto auto 50%; translate: -50% -50%;
inline-size: 44px; block-size: 44px; border-radius: 50%; /* фактическая цель 44×44 */
}
.toolbar { display: flex; gap: 8px; } /* две цели впритык — одна цель для человека с тремором */
Ловушка: ::after расширяет цель, но overflow: hidden у родителя обрежет её обратно. Проверять нужно поведение, а не CSS — кликните в 15px от центра иконки и посмотрите, сработает ли.
6. Движение и анимация
Почему это не вкусовщина
Движение на экране вызывает у части людей физиологические реакции: тошноту, головокружение, дезориентацию, приступы мигрени. Это касается людей с вестибулярными нарушениями — вестибулярная мигрень, болезнь Меньера, последствия сотрясения. Отдельная и более опасная история — светочувствительная эпилепсия: мигание определённой частоты и площади способно спровоцировать приступ.
| Критерий | Уровень | Требование |
|---|---|---|
| 2.3.1 Three Flashes or Below Threshold | A | не более трёх вспышек в секунду либо ниже порогов яркости и площади |
| 2.2.2 Pause, Stop, Hide | A | любое автозапускающееся движение дольше 5 секунд можно остановить |
| 2.3.3 Animation from Interactions | AAA | анимацию, вызванную действием пользователя, можно отключить |
Порог 2.3.1 формулируется через площадь: опасна вспышка, занимающая более 25% центрального поля зрения (примерно 21% площади экрана при типичном расстоянии просмотра). Видео и анимацию проверяют инструментом PEAT от Trace Center.
prefers-reduced-motion: как это делают правильно
Человек включает системную настройку — «Уменьшить движение» в macOS и iOS, отключение анимаций в Windows, аналогичные переключатели в Android и GNOME — и браузер транслирует её в медиазапрос. Ключевая мысль, которую часто пропускают: reduced ≠ removed. Нужно убрать дезориентирующее движение (полёты, параллакс, масштабирование, движение больших площадей), а не лишить человека обратной связи. Плавное появление по прозрачности обычно безопасно и полезно: оно объясняет, что элемент новый.
.modal { animation: slide-up 240ms cubic-bezier(.2, 0, 0, 1); }
@keyframes slide-up { from { opacity: 0; translate: 0 24px; } to { opacity: 1; translate: 0 0; } }
@media (prefers-reduced-motion: reduce) {
.modal { animation: fade-in 120ms linear; } /* движение убрали, отклик оставили */
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
.parallax { transform: none !important; } /* параллакс выключаем полностью */
/* Страховка от чужого кода — но не замена осмысленной работе выше */
*, *::before, *::after {
animation-duration: .01ms !important; animation-iteration-count: 1 !important;
transition-duration: .01ms !important; scroll-behavior: auto !important;
}
}
Почему 0.01ms, а не 0s: при нулевой длительности события transitionend и animationend в части браузеров не срабатывают, и логика, которая ждёт их, чтобы размонтировать модалку, зависает навсегда; микроскопическая длительность гарантирует, что событие придёт (prefers-reduced-motion на MDN). Для JS-анимаций (GSAP, Framer Motion, Web Animations API, canvas) CSS ничего не решает: читайте настройку явно и подписывайтесь на её изменение.
const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
const duration = (ms) => (motionQuery.matches ? 0 : ms);
// Настройку переключают, не перезагружая страницу: гасим бесконечные циклы и rAF
motionQuery.addEventListener("change", (e) => e.matches && stopAmbientAnimations());
element.animate(
[{ opacity: 0, transform: "translateY(24px)" }, { opacity: 1, transform: "none" }],
{ duration: duration(240), easing: "cubic-bezier(.2, 0, 0, 1)", fill: "both" }
);
Автокарусели и жизненный цикл движения
Самопереключающаяся карусель — концентрат нарушений: движение без паузы (2.2.2), навязанный таймер на чтение (2.2.1), контент, исчезающий под пальцами. Если карусель всё же нужна, её жизненный цикл должен выглядеть так.
Ключевой нюанс — после ручного переключения автоплей не возобновляется: иначе человек, остановивший карусель, чтобы дочитать, снова теряет контент. Ещё две вещи из той же оперы, формально не про анимацию, но воспринимаемые как движение. Скачки макета при загрузке (шрифты, картинки без width/height, поздние баннеры) — то же дезориентирующее движение, только незапланированное; лечится резервированием места через aspect-ratio и атрибуты размеров. Тултипы и поповеры по наведению подпадают под 1.4.13 Content on Hover or Focus: их должно быть можно закрыть по Escape, на них должно быть можно навести курсор, и они не должны исчезать сами по таймеру.
7. Принудительные цвета, темы и системные настройки
forced-colors: контрастные темы Windows
Windows Contrast Themes (бывший High Contrast) — не «фильтр поверх страницы». Система подменяет цвета по ролям элементов: текст, кнопка, ссылка, выделение; пользователь получает жёстко заданную палитру, где всё гарантированно контрастно. Что ломается почти всегда: иконки, нарисованные через background-image (фоновые изображения не перекрашиваются и часто пропадают вместе с фоном); элементы, где смысл держится на background-color — цветные бейджи, тумблеры, полосы прогресса; «кнопки» из <div>, у которых система не знает роли (ещё один аргумент за семантику); тени box-shadow, которые не выживают, — в отличие от border.
@media (forced-colors: active) {
.card { border: 1px solid CanvasText; background: Canvas; color: CanvasText; }
.tab[aria-selected="true"] { border-bottom: 3px solid Highlight; } /* состояние не только фоном */
:focus-visible { outline: 3px solid Highlight; outline-offset: 2px; }
.color-swatch { forced-color-adjust: none; } /* законно, когда цвет и есть содержимое */
}
Полный список системных ключевых слов — Canvas, CanvasText, LinkText, VisitedText, ActiveText, ButtonFace, ButtonText, ButtonBorder, Field, FieldText, Highlight, HighlightText, GrayText, Mark, MarkText — в справочнике system-color на MDN; эмуляция в DevTools → Rendering → Emulate CSS media feature forced-colors. Правило для иконок: рисуйте их inline-SVG с fill="currentColor" — тогда они наследуют цвет текста и корректно перекрашиваются и в тёмной теме, и в forced-colors, и при выделении.
prefers-contrast, color-scheme и light-dark()
:root {
color-scheme: light dark; /* нативные контролы и скроллбары перекрасятся сами */
--surface: light-dark(#ffffff, #12161c);
--text: light-dark(#1f2933, #e6e8eb);
--border: light-dark(#8a94a6, #6b7480); /* 3:1 к фону в обеих темах */
}
@media (prefers-contrast: more) { /* попросили больше контраста */
:root { --text: light-dark(#000, #fff); --border: light-dark(#000, #fff); }
.button { border-width: 2px; }
}
@media (prefers-contrast: less) { :root { --text: light-dark(#3d4753, #c3c8d0); } }
Из той же семьи пригодятся prefers-reduced-transparency (просили убрать прозрачность и размытие — снимайте backdrop-filter) и prefers-reduced-data. Справочники: color-scheme, light-dark(), prefers-contrast. И отдельно: не делайте «версию для слабовидящих» отдельной страницей. Один интерфейс, уважающий системные настройки и имеющий переключатель темы, работает лучше и не превращается через год в заброшенное кладбище устаревшего контента. Аргументы разобраны в статье Зачем это нужно.
8. Ручная проверка за пять минут
Никакой линтер не заменит эту последовательность. Открываете свою страницу и:
| # | Действие | Что смотрим | Провал, если |
|---|---|---|---|
| 1 | Ctrl + «+» до 200% | вся страница | пропал текст, обрезались кнопки, форма стала неюзабельной |
| 2 | Ctrl + «+» до 400% при окне 1280px | вся страница | появилась горизонтальная прокрутка у обычного контента |
| 3 | Rendering → Emulate vision deficiencies → Achromatopsia | статусы, графики, ссылки, ошибки | смысл различим только по цвету |
| 4 | Rendering → forced-colors: active | иконки, бейджи, границы, состояния | иконки исчезли, активная вкладка неотличима |
| 5 | Вставить CSS text-spacing из раздела 4 | плотные блоки, кнопки, таблицы | текст обрезался или наехал |
| 6 | Пипетка DevTools по светлому тексту | Contrast ratio в color picker | нет галочки AA (или ниже 3:1 у границ) |
| 7 | Rendering → prefers-reduced-motion: reduce | карусели, параллакс, переходы | движение осталось |
| 8 | Нажимать в 10px от центра мелких иконок | тулбары, крестики, чекбоксы | промахи, цель меньше 24px без зазоров |
Пункт 6 особенно удобен: в color picker Chrome коэффициент показан сразу, а на цветовом поле нарисована линия порога — маркер можно тянуть вдоль неё и найти ближайший проходящий оттенок, сохранив тон (DevTools Accessibility reference). Дополнительно стоит хотя бы раз посмотреть на страницу так: на телефоне при ярком солнце (самый честный тест контраста, который существует), распечатанной в оттенках серого (заодно проверка 1.4.1), с системным размером шрифта 200% (именно настройкой ОС, а не зумом браузера) и через экранную лупу на увеличении 4× — сразу становится понятно, как трудно удерживать контекст, когда видна четверть страницы.
9. Как это живёт в продукте
Разовый аудит контрастов протухает за два спринта. Работает только встраивание в конвейер, и вот что окупается.
1. Контраст проверяется на уровне токенов, а не страниц. Тест палитры ловит проблему один раз для всего продукта.
import { describe, expect, it } from "vitest";
import { contrastRatio } from "./contrast"; // та же формула, что в Python выше
import { colors } from "../tokens/colors";
// Пары, которые дизайн-система обещает поддерживать, и их минимальные пороги
const PAIRS: Array<[keyof typeof colors, keyof typeof colors, number]> = [
["textPrimary", "surface", 4.5], ["textSecondary", "surface", 4.5],
["textOnAccent", "accent", 4.5],
["borderDefault", "surface", 3.0], ["iconMuted", "surface", 3.0], // 1.4.11
];
describe("контраст токенов", () => {
it.each(PAIRS)("%s на %s не ниже %f:1", (fg, bg, min) =>
expect(contrastRatio(colors[fg], colors[bg])).toBeGreaterThanOrEqual(min));
});
2. Автоматика покрывает контраст, но не покрывает reflow. Прогоняйте axe и держите e2e-проект с узким вьюпортом; переполнение ловится простым скриптом.
// Playwright, проект с вьюпортом 320×256: элементы, вылезающие за правую границу
const overflowing = await page.evaluate(() => {
const limit = document.documentElement.clientWidth;
return [...document.querySelectorAll("*")]
.filter((el) => el.getBoundingClientRect().right > limit + 1)
.map((el) => el.tagName + "." + el.className).slice(0, 20);
});
expect(overflowing, `Переполнение при 320px: ${overflowing}`).toHaveLength(0);
// В CI рядом: npx @axe-core/cli https://example.com/checkout --tags wcag2aa,wcag22aa
3. Критерии приёмки формулируются числами. Не «сделать доступно», а: «текст 4,5:1, границы 3:1, цель 24×24 с зазором 8px, форма работает при 400% и при увеличенных интервалах, анимация уважает prefers-reduced-motion». Такое ревьюер проверяет за минуту, и такое нельзя «в целом сделать».
4. У дизайнера должны быть проходящие токены изначально. Если в макете серый #9aa5b1 на белом, разработчик либо сверстает недоступно, либо будет спорить на каждом ревью. Дешевле один раз починить палитру; офлайн-инструмент — Colour Contrast Analyser от TPGi.
Читается это так: правки в дизайн-токенах дают максимальный эффект почти бесплатно — с них и начинают. Reflow дорог (это переработка раскладки), но эффект огромен, поэтому идёт в план, а не в «когда-нибудь». Forced-colors — важная, но узкая история, её закрывают один раз на уровне UI-kit. Переезд на APCA сегодня не является задачей продукта. Про процесс, инструменты и организацию аудита — в статье Тестирование и процесс; про CSS-механику, на которую всё это опирается, — в треке Frontend: основы CSS и раскладка.
10. Типичные ошибки
| Ошибка | Почему ломается | Как правильно |
|---|---|---|
| Серый плейсхолдер вместо подписи | контраст ниже 4,5:1, подпись исчезает при вводе | видимый <label>, плейсхолдер только как пример формата |
Рамка поля #e0e0e0 на белом |
1,2:1 — поле не читается как поле | граница не ниже 3:1 к фону (1.4.11) |
Текст на фото с text-shadow |
контраст зависит от кадра, автопроверка даёт incomplete | плотный скрим или плашка под текстом |
#fff на #000 в тёмной теме |
halation, буквы «плывут» | #e6e8eb на #12161c |
| Ошибка формы только красной рамкой | не видна части пользователей, молчит для скринридера | иконка плюс текст ошибки, связанный с полем |
height в px у карточек и кнопок |
ломается при зуме и увеличенных интервалах | min-height плюс padding |
html { font-size: 62.5% } и user-scalable=no |
обнуляют пользовательскую настройку шрифта и пинч-зум | корневые 100%, размеры в rem, initial-scale=1 |
Иконки через background-image |
пропадают в forced-colors | inline-SVG с fill="currentColor" |
transition: none при reduced-motion |
ломает логику, ждущую transitionend |
.01ms вместо 0s, движение заменить на fade |
| Карусель без кнопки паузы | нарушение 2.2.2, контент уезжает из-под пальцев | видимая пауза, остановка на hover и focus, без возобновления |
| Крестик закрытия 16×16 в углу | промахи, нарушение 2.5.8 | цель 44×44 псевдоэлементом, зазор 8px |
| Контраст проверили один раз перед релизом | новые токены приходят каждый спринт | тест палитры в CI |
Мини-итог
- Контраст — арифметика, а не вкус: 4,5:1 для текста, 3:1 для крупного текста, границ, иконок и фокуса, 7:1 для AAA. Формула WCAG 2.x несовершенна на тёмных темах и тонких шрифтах, но сегодня нормативна именно она; APCA — вероятное будущее.
- Цвет — усиление, а не носитель смысла. Проверка одной кнопкой: включите ахроматопсию в DevTools.
- Масштаб — это три разных требования: зум 200%, reflow в 320 CSS px, увеличенные текстовые интервалы. Фиксированные высоты в px ломают все три, а
remвместоpx— единственный способ уважать пользовательскую настройку размера шрифта. - Цель минимум 24×24 CSS px, здоровая норма — 44×44; расширяйте зону нажатия псевдоэлементом, не раздувая макет.
- Движение уменьшают, а не выключают:
prefers-reduced-motionменяет полёты на плавное появление, автоплей — на управляемое воспроизведение. - forced-colors и prefers-contrast — не экзотика, а способ бесплатно получить идеальную читаемость для тех, кто уже настроил систему под себя.
- Всё это живёт в токенах и CI, иначе через два спринта в палитре снова появится серый текст.
Источники
- WCAG 2.2 и Understanding WCAG 2.2 — нормативный текст и объяснения критериев 1.4.1, 1.4.3–1.4.13, 2.3.1–2.3.3, 2.5.5, 2.5.8, 1.3.4.
- WAI-ARIA Authoring Practices — эталонные паттерны компонентов, включая карусель с управлением воспроизведением.
- MDN: prefers-reduced-motion, forced-colors, system-color, color-scheme.
- WebAIM Contrast Checker и WebAIM Million — низкий контраст остаётся дефектом номер один примерно на 79–80% главных страниц; Colour Contrast Analyser — офлайн-пипетка.
- APCA — перцептивная метрика контраста, кандидат для WCAG 3; PEAT — анализ мигания на пороги светочувствительности; Chrome DevTools: Accessibility reference.
- Val Head. Designing With Reduced Motion For Motion Sensitivities — какие виды движения вызывают проблемы и чем их заменять; Vestibular Disorders Association — о вестибулярных нарушениях; ВОЗ о нарушениях зрения — масштаб явления.
Что дальше
Визуальный слой разобран: контраст посчитан, макет переживает 400%, движение уважает настройки системы. Дальше — место, где сходятся все линии доступности сразу и где цена ошибки максимальна, потому что человек не просто читает, а вводит данные и получает отказ: Доступные формы: подписи, подсказки, ошибки, валидация.