Контрастность 4.5:1 — как проверять и автоматизировать в CI
Каждый раз, когда дизайнер показывает мне макет с серым текстом на белом фоне «для тонкости», я молча открываю DevTools и проверяю контрастность. В половине случаев получается 3:1 или хуже. То есть текст, который дизайнеру кажется «изящным», для пожилого пользователя или человека с нарушениями зрения — невидимый шум.
WCAG требует контраст 4.5:1 для основного текста и 3:1 для крупного. Это не пожелание, это число, которое можно посчитать и проверить автоматически. Эта статья — про то, как считать контраст, какие инструменты использовать в каждый момент работы и как поставить проверку в CI, чтобы дизайнерское «оно тонкое» не доходило до прода.
Что такое контрастность 4.5:1
Формула из WCAG 2.1: (L1 + 0.05) / (L2 + 0.05), где L1 — относительная яркость более светлого цвета, L2 — более тёмного. Минимум 1:1 (одинаковые цвета, ничего не видно), максимум 21:1 (чёрный на белом). Для основного текста — 4.5:1, для крупного (от 18pt или 14pt жирного) — 3:1, для интерфейсных элементов и иконок — 3:1.
Считать формулу руками не нужно. Все инструменты делают это сами. Но понимать, что 4.5:1 — это не «темновато» и не «светловато», а конкретное число, важно, чтобы не спорить с дизайнером по ощущениям.
Где смотреть в момент написания CSS
Самый быстрый способ — Chrome DevTools. Тыкаешь в любой текст, в панели Styles рядом со свойством color появляется маленький квадратик. Он показывает текущий контраст и помечает зелёной галочкой, если соблюдён минимум.
Если квадратика нет — значит у тебя color унаследован от родителя, и DevTools не уверены в фоне. Кликни на сам компьютерный стиль (Computed) и посмотри финальные значения color и background-color. Их можно вбить в любой онлайн-калькулятор контраста, но я обычно делаю проще: открываю Color Picker (клик по плашке цвета в Styles), и там тоже виден индикатор APCA или WCAG.
Полезный лайфхак: в DevTools на вкладке Rendering есть переключатель «Emulate vision deficiencies». Дальтонизм, частичная потеря зрения, нет красного канала. Иногда видишь, что иконки, понятные «нормальному» глазу, для дальтоника сливаются с фоном.
Где ловятся 80% проблем с контрастом
За последние два года я разбирала десятки баг-репортов. Вот что встречается чаще всего:
- Плейсхолдеры в инпутах. Браузер по умолчанию рисует их серым. На белом фоне — обычно 3.5:1. Браузер не виноват, его настройка по дефолту слишком светлая. В дизайн-системе всегда переопределяй
::placeholderс проверенным контрастом. - Подсказки и hint-тексты под полями. Дизайнеры любят их «приглушать». Серый
#9ca3afна белом — 2.85:1. Не проходит. Минимум —#6b7280(4.6:1). - Disabled-элементы. Тут хитрость: WCAG не требует контраста для disabled. Но если кнопка disabled выглядит точно так же, как enabled, пользователи путаются. Хорошая практика — disabled с контрастом 3:1, чтобы текст был читаемым, но визуально приглушённым.
- Текст поверх изображений. Самое сложное. Контраст зависит от того, какой именно пиксель сейчас под буквой. Решение — полупрозрачная подложка под текст (
background: rgba(0,0,0,0.5)) или градиент с гарантированной зоной нужного контраста. - Брендовые цвета. «Наш фирменный синий» часто оказывается на грани. Проверяй каждый brand color на каждом фоне, на котором он используется.
Линтер на стадии написания
В stylelint нет встроенной проверки контраста между токенами. Но есть плагин stylelint-a11y:
npm install --save-dev stylelint stylelint-a11y{
"plugins": ["stylelint-a11y"],
"rules": {
"a11y/no-outline-none": true,
"a11y/media-prefers-reduced-motion": true,
"a11y/no-display-none": null
}
}Контраст он не проверит — он не знает, какие пары цветов реально используются вместе. Реально контраст проверяется только на отрендеренной странице, и для этого нужен браузерный инструмент.
Storybook + axe для проверки токенов
Если у тебя дизайн-система, удобно проверять контраст токенов через Storybook. Подключаем @storybook/addon-a11y:
npm install --save-dev @storybook/addon-a11y// .storybook/main.ts
export default {
addons: ["@storybook/addon-a11y"],
};Аддон использует axe-core и проверяет каждую сториз на наличие проблем с контрастом. Я обычно завожу отдельную сториз «Все цветовые комбинации»: рендерю grid, где каждая ячейка — текст одного семантического токена на фоне другого. Если где-то контраст плохой — аддон сразу подсветит.
export const ContrastMatrix = () => {
const fgTokens = ["primary", "secondary", "muted", "disabled"];
const bgTokens = ["page", "surface", "surface-muted"];
return (
<div style={{ display: "grid", gridTemplate: "auto / repeat(3, 1fr)" }}>
{bgTokens.flatMap(bg =>
fgTokens.map(fg => (
<div
key={`${fg}-${bg}`}
style={{
color: `var(--color-text-${fg})`,
background: `var(--color-bg-${bg})`,
padding: 16,
}}
>
{fg} on {bg}
</div>
))
)}
</div>
);
};Автоматизация в CI: axe-core + Playwright
Собственно про CI. Идея: при каждом PR прогонять рендер ключевых страниц через Playwright и стрелять в них axe-core. Если хоть один фейл — PR красный.
npm install --save-dev @playwright/test @axe-core/playwright// tests/a11y.spec.ts
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
const pages = [
{ url: "/", name: "home" },
{ url: "/login", name: "login" },
{ url: "/dashboard", name: "dashboard" },
];
for (const { url, name } of pages) {
test(`${name} — нет проблем с контрастом`, async ({ page }) => {
await page.goto(url);
await page.waitForLoadState("networkidle");
const results = await new AxeBuilder({ page })
.withTags(["wcag2aa"])
.include("main")
.analyze();
const contrastIssues = results.violations.filter(
v => v.id === "color-contrast"
);
expect(contrastIssues).toEqual([]);
});
}Что тут важно. Я фильтрую именно color-contrast, потому что axe возвращает много других проверок (alt, label, role), и хочу разделить отчёты по типам. На больших проектах это удобно: контраст катит one team, а лейблы исправляются другой.
Подключение в GitHub Actions:
name: a11y
on: [pull_request]
jobs:
contrast:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run build
- run: npx playwright install chromium
- run: npm run test:a11y
env:
BASE_URL: http://localhost:3000Запуск локально перед коммитом — через husky и lint-staged. На большом проекте полный прогон долгий, поэтому в pre-commit запускаю только axe на изменённых страницах. Полная проверка — в CI.
Что делать с false positives
axe иногда промахивается. Самый частый случай — текст на градиентной подложке. axe смотрит на computed background-color, для градиента это часто прозрачное значение, и axe считает контраст с белым по умолчанию (а реальный фон — тёмный). Получается ложное предупреждение.
Решение — либо отключить эту проверку для конкретных селекторов, либо добавить дополнительную обёртку с явным фоном для проверки. Я предпочитаю второе — это заставляет проектировать страницы так, чтобы фон под текстом был очевиден.
const results = await new AxeBuilder({ page })
.disableRules([])
.exclude(".hero-banner") // тут гарантированный контраст вручную
.analyze();Не злоупотребляй exclude. Каждое исключение — компромисс, и каждое должно быть с комментарием в коде, почему. Иначе через полгода никто не вспомнит, и под этим селектором накопятся реальные проблемы.
APCA: что с ним
APCA — это новая модель контраста, которая идёт на замену WCAG 2.x. Она лучше учитывает восприятие тонкого текста и шрифтов разной толщины. WCAG 3.0 (которая в драфте уже несколько лет) использует именно APCA.
В Chrome DevTools уже есть переключатель между WCAG и APCA. Но в проде я пока ориентируюсь на WCAG 2.1 — он официальный, на него ссылаются юристы и стандарты, и axe-core по умолчанию проверяет именно его. Когда WCAG 3.0 будет принят, и axe начнёт проверять APCA — тогда переключусь.
Что унести с собой
Контраст — единственная метрика доступности, которая полностью автоматизируется. Не пускай её в самотёк. Включи проверку в DevTools на каждый день, в Storybook на стадии разработки токенов, в Playwright + axe на CI. Если пайплайн настроен один раз, дальше команда просто не может протащить плохой контраст в прод.
Главное правило: 4.5:1 не обсуждается. Это не «согласовать с дизайнером», это требование стандарта. Если дизайнеру кажется, что текст «слишком яркий» — поменять фон, поменять шрифт, добавить вес. Уменьшать контраст ниже 4.5:1 нельзя.