Цвет, контраст и движение

Цвет — самый ненадёжный канал передачи смысла: его по-разному видят сотни миллионов человек, а на солнце его не видит вообще никто.

Контраст — числовое отношение яркости текста к яркости фона: от 1:1 (невидимо) до 21:1 (чистый чёрный на чистом белом). Для обычного текста WCAG требует минимум 4.5:1.

Зачем это на практике

Низкий контраст — самый массовый дефект доступности в вебе: ежегодное исследование WebAIM Million годами находит нечитаемый текст примерно на восьми из десяти главных страниц. И это не про экзотику. Это про:

  • ~8% мужчин и ~0,5% женщин с нарушением цветовосприятия (около 300 млн человек в мире);
  • возрастное помутнение хрусталика — после 50 лет контрастная чувствительность падает у всех;
  • телефон на улице в солнечный день — временная слепота на бледный текст;
  • дешёвый монитор в офисе, режим энергосбережения, ночной фильтр синего;
  • просто усталые глаза в конце рабочего дня.

Контраст — тот редкий случай, когда доступность буквально измеряется числом. Не надо спорить с дизайнером «мне кажется, светловато» — есть формула.

Контраст: цифры, которые надо знать

ЧтоМинимум (уровень AA)Повышенный (AAA)
Обычный текст (меньше 24px, или меньше 18.66px жирного)4.5:17:1
Крупный текст (от 24px, или от 18.66px жирного)3:14.5:1
Границы полей, иконки, элементы управления3:1
Логотипы, чисто декоративный тексттребований нет

Смотрите, как мало нужно, чтобы промахнуться:

/* Плохо: 2.6:1 — не проходит даже близко */
.hint { color: #9aa0a6; background: #ffffff; }

/* Хорошо: 4.5:1 — минимально допустимый серый на белом */
.hint { color: #767676; background: #ffffff; }

На глаз разница невелика. Для человека с возрастным снижением зрения — это разница между «читаю» и «не читаю».

Где контраст проваливают чаще всего

  • Плейсхолдеры. Браузерный серый по умолчанию балансирует на грани — а его ещё и осветляют «для красоты».
  • Белый текст на брендовой кнопке. Классика жанра: жёлтый #FFC107 + белый текст даёт 1.6:1 — практически нечитаемо. А чёрный на том же жёлтом — 12.9:1. Иногда доступность требует просто поменять цвет текста.
  • Текст поверх фотографии. Контраст «плавает» по площади: над тёмным углом всё отлично, над облаком — ничего не видно. Лечится полупрозрачной подложкой или градиентом, а не надеждой.
  • Ссылка в абзаце, отличающаяся только цветом — про это ниже, это отдельный критерий.
  • Тёмная тема. Отдельная проверка, а не «инвертировали и поехали». Чисто белый #fff на чисто чёрном #000 даёт формальные 21:1 — и эффект «гало»: буквы расплываются, особенно при астигматизме. Мягче и правильнее: #e8e8e8 на #121212 — это 15:1 и никакого свечения.

Считать контраст на глаз нельзя. Используйте контраст-чекер (WebAIM Contrast Checker), встроенную пипетку DevTools (она показывает отношение прямо в палитре) или axe/Lighthouse — они прогонят всю страницу. Считать нужно на этапе дизайна: перекрашивать готовый интерфейс дороже, чем поправить токены в макете.

Смысл только цветом — так нельзя

Критерий WCAG 1.4.1 звучит так: цвет не должен быть единственным средством передачи информации, обозначения действия или различения элементов. Вот типичные нарушения — почти наверняка вы их видели на этой неделе:

  • «Поля, выделенные красным, обязательны для заполнения»;
  • красная рамка вокруг поля — и ни слова текста;
  • легенда графика: «синяя линия — 2024, оранжевая — 2025»;
  • статусы заявок в таблице: цветные кружки без подписи;
  • ссылка внутри текста, которая отличается от обычного текста только цветом.

Последний пункт коварнее всех. Возьмите два цвета, которые для зрячего различаются как небо и земля: красный #FF0000 и зелёный #00A000. Их контраст друг с другом — примерно 1.15:1. Для человека с дейтеранопией они сливаются в один тон. Именно поэтому WCAG требует, чтобы ссылка либо была подчёркнута, либо контрастировала с окружающим текстом минимум на 3:1.

Как чинить: добавьте второй канал

Цвет оставляем — он полезен и приятен. Просто дублируем его чем-то ещё:

  • словом («Отклонено», «Ошибка:», «Готово»);
  • иконкой с текстовой альтернативой (крестик, галочка, восклицательный знак);
  • подчёркиванием — для ссылок в тексте;
  • штриховкой и формой — на графиках: разные типы линий, разные маркеры, подписи прямо у линий вместо легенды сбоку.
<p class="status status--error">
  <svg aria-hidden="true" focusable="false" width="16" height="16"></svg>
  <strong>Отклонено:</strong> не хватает документов
</p>

Красный тут остаётся, но он больше не несёт смысл в одиночку: смысл несёт слово «Отклонено».

.article a {
  color: #0b57d0;
  text-decoration: underline;
}

/* Никогда: outline: none; — это отключение клавиатуры */
.article a:focus-visible {
  outline: 3px solid #0b57d0;
  outline-offset: 2px;
}

outline: none — самый вредный однострочник в истории CSS. Мышью всё работает, поэтому автор правки ничего не замечает; а человек, который ходит по сайту клавиатурой, теряет курсор и не понимает, где находится.

Проверка за 10 секунд

В Chrome DevTools откройте панель Rendering → Emulate vision deficiencies и включите achromatopsia (полное отсутствие цветовосприятия). Если в чёрно-белом интерфейс остаётся понятным — вы использовали цвет как усиление. Если статусы, ошибки и ссылки исчезли — цвет у вас несёт смысл в одиночку, и это надо чинить.

Движение: анимации, от которых тошнит

Это не преувеличение и не про «кому-то не нравится». У людей с вестибулярными расстройствами (после сотрясения, при мигрени, при болезни Меньера) крупная анимация вызывает настоящее головокружение и тошноту — иногда на несколько часов. Критерий WCAG 2.3.3 существует именно поэтому.

Опасно то движение, которое занимает большую площадь экрана и создаёт ощущение, что двигается сам мир:

  • параллакс при прокрутке (слои едут с разной скоростью);
  • зум-переходы между экранами, «влетающие» и «улетающие» модалки;
  • вращения, спирали, большие смещения;
  • бесконечные автокарусели и бегущие строки;
  • фоновое видео с движением камеры.

Относительно безопасны: изменение прозрачности (fade), плавная смена цвета, короткие сдвиги на несколько пикселей.

prefers-reduced-motion

В системе есть переключатель «Уменьшить движение» (macOS: Универсальный доступ → Дисплей; Windows: Специальные возможности → Визуальные эффекты; в Android и iOS аналогично). Браузер сообщает о нём CSS медиазапросом — и это единственный правильный способ узнать, что человеку нужен спокойный интерфейс.

.card { transition: transform 300ms ease; }
.card:hover { transform: translateY(-8px) scale(1.04); }

@media (prefers-reduced-motion: reduce) {
  .card { transition: none; }
  .card:hover { transform: none; }
}

Общий «предохранитель» на весь проект — страховка от того, что кто-то забудет:

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

Обратите внимание: 0.01ms, а не 0s. Нулевая длительность в части браузеров не порождает событие animationend — и логика на JS, которая его ждёт, зависает навсегда. Микроскопическая длительность и движение убирает, и событие сохраняет.

Но выключать всё подряд — грубо. Правильнее заменять движение на спокойный эквивалент:

@media (prefers-reduced-motion: no-preference) {
  .modal { animation: slide-up 250ms ease-out; }
}

@media (prefers-reduced-motion: reduce) {
  .modal { animation: fade-in 150ms linear; }
}

Здесь важен приём: анимацию мы включаем внутри no-preference. Тогда «без движения» становится состоянием по умолчанию, и забытый уголок интерфейса не превратится в проблему.

Если анимацию рисует JavaScript (canvas, WebGL, конфетти после покупки), тот же флаг читается из скрипта:

const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

if (reduceMotion) {
  showStaticSuccessBadge();
} else {
  launchConfetti();
}

Ещё три правила про движение

  • Мигание — не чаще 3 раз в секунду (WCAG 2.3.1). Более частое мигание — реальный риск фотосенситивного эпилептического приступа. Это одно из немногих требований, нарушение которого может отправить человека в больницу.
  • Всё, что движется дольше 5 секунд, должно иметь паузу (WCAG 2.2.2): карусель, бегущая строка, автообновляемая лента. Кнопка «Пауза» — обязательна, и не в подвале, а рядом.
  • Автокарусель на главной — почти всегда плохая идея. Она мешает и человеку с СДВГ, и тому, кто просто читает медленнее, чем вы задали таймер.

Как это работает

Контраст в WCAG считается по формуле (L1 + 0.05) / (L2 + 0.05), где L — относительная яркость (relative luminance) более светлого и более тёмного цвета. И вот главное: яркость — это не среднее арифметическое каналов RGB. Каналы берутся с весами, отражающими чувствительность человеческого глаза:

L = 0.2126 * R + 0.7152 * G + 0.0722 * B
     (~21%)      (~72%)      (~7%)

Зелёный даёт почти три четверти воспринимаемой яркости, синий — почти ничего. Отсюда следуют вещи, которые сначала кажутся парадоксом:

  • Жёлтый почти не контрастирует с белым (в нём много красного и зелёного — он «яркий»), зато отлично работает с чёрным.
  • Насыщенно-синий на белом читается прекрасно, хотя «на глаз» кажется не таким уж тёмным: синий канал почти не добавляет яркости.
  • Разные цвета ≠ контрастные цвета. Тот самый красный и зелёный с контрастом 1.15:1 — разные до противоположности, но по яркости почти одинаковые.

Отсюда же ответ на вечный вопрос «а почему нельзя просто взять контрастную палитру и не думать?». Потому что контраст — это про яркость, а различимость для дальтоника — про тон. Это две разные оси, и проходить надо по обеим: сначала измерить контраст, потом посмотреть на интерфейс в чёрно-белом.

Частые ошибки

ОшибкаЧем плохаКак правильно
Светло-серый текст «для воздуха»2.5–3:1, не читается на солнце и после 50 летминимум 4.5:1, проверять чекером
Белый текст на жёлтой/светло-зелёной кнопкеоколо 1.6:1 — надпись сливаетсятёмный текст на светлом фирменном цвете
«Обязательные поля выделены красным»смысл только цветом (WCAG 1.4.1)слово, звёздочка с расшифровкой, required
Ссылка в тексте без подчёркиваниядальтоник её не отличит от текстаподчёркивание или контраст 3:1 с текстом
outline: none на фокусеклавиатурный пользователь теряет курсорзаметный :focus-visible
Инвертировали цвета и назвали это тёмной темой#fff на #000 даёт «гало», текст расплывается#e8e8e8 на #121212, контраст мерить заново
Параллакс и зум без prefers-reduced-motionвызывает головокружение и тошнотузаменить на fade в ветке reduce
animation-duration: 0s в reduce-веткесобытие animationend может не сработать0.01ms
Автокарусель без паузынарушает WCAG 2.2.2кнопка «Пауза» или отказ от карусели

Итоги

  • Контраст — единственная часть доступности, которая измеряется числом: 4.5:1 для обычного текста, 3:1 для крупного и для элементов управления.
  • Яркость считается с весами (зелёный ~72%, синий ~7%). Поэтому жёлтый плохо дружит с белым, а «разные цвета» вовсе не значит «контрастные».
  • Цвет никогда не должен быть единственным носителем смысла: дублируйте словом, иконкой, подчёркиванием, формой.
  • Быстрая проверка: включите в DevTools ахроматопсию. Понятно в чёрно-белом — значит, всё хорошо.
  • Крупное движение (параллакс, зум, вращение) реально вызывает тошноту. Уважайте prefers-reduced-motion — и лучше включайте анимации внутри no-preference, а не выключайте внутри reduce.
  • Ничего не мигает чаще 3 раз в секунду; всё, что движется дольше 5 секунд, можно остановить.
Проверьте себя
1. Дизайнер сделал кнопку фирменным жёлтым #FFC107 с белым текстом. Контраст — 1.6:1. Что делать?
AНичего: для крупного текста кнопки требований нет
BСделать текст чёрным — на том же жёлтом это даёт около 12.9:1, фирменный цвет сохраняется
CДобавить белому тексту тень — она визуально усилит контраст и закроет требование WCAG
2. Зачем в reduce-ветке пишут animation-duration: 0.01ms, а не 0s?
A0s игнорируется браузером, а 0.01ms применяется корректно
B0.01ms всё же оставляет чуть-чуть движения, что мягче для глаза
CПри нулевой длительности событие animationend может не сработать, и JS-логика, которая его ждёт, зависнет