Когда дизайн известен заранее, цвет текста подбирается глазом: бренд-цвет на палитре, к нему сверху чёрный или белый, дизайнер всё проверил. Сложнее, когда фон элемента приходит из данных — в бейдже статуса с настраиваемой заливкой, в теге, который автор поста выбрал сам, в столбике диаграммы с цветом из категории. Один color на все случаи не подойдёт: на жёлтом белый текст исчезает, на тёмно-синем чёрный сливается. Нужен алгоритм, который для произвольного фона возвращает читаемый цвет шрифта.
Самый практичный вариант для бинарного выбора «чёрный или белый» — формула относительной яркости из WCAG. Дальше разберём её по шагам и соберём короткую функцию на JavaScript.
Что считается контрастом
Контраст — это число, которое описывает, насколько сильно два цвета отличаются по яркости. У пары «чёрный текст на белом фоне» контраст максимальный, у пары «серый текст на чуть менее сером фоне» — почти нулевой. Чем больше число, тем легче читать.
Точное определение задаёт WCAG — Web Content Accessibility Guidelines, стандарт W3C по доступности веб-страниц. Контраст в нём считается так:
contrast = (L1 + 0.05) / (L2 + 0.05)
Здесь L1 — относительная яркость более светлого из двух цветов (числом от 0 до 1), L2 — более тёмного. Прибавка 0.05 страхует от деления на ноль для чистого чёрного. Результат — число вида 1:1 (когда цвета одинаковые) до 21:1 (чёрный на белом, максимум).
Несколько живых пар, чтобы было с чем сверяться:
WCAG задаёт два уровня соответствия: AA и AAA. Это не «оценки» и не сертификация — это пороги, которые сайт либо проходит, либо нет. AA — базовый уровень доступности, к нему обычно стремятся коммерческие сайты и закон требует именно его в публичном секторе ЕС и США. AAA — повышенный уровень для критичных случаев (государственные сервисы, медицина, тексты для людей с пониженным зрением).
Пороги для текста:
- AA: 4.5:1 для основного текста, 3:1 для крупного (от 18pt или 14pt полужирный — примерно 24px и 18.7px-bold в браузере по умолчанию);
- AAA: 7:1 для основного текста, 4.5:1 для крупного.
Если ограничиться выбором между #000 и #fff, задача упрощается. У белого L = 1, у чёрного L = 0. Подставив обе пары в формулу контраста и решив неравенство относительно яркости фона, можно вывести короткое правило: если L фона больше примерно 0.179 — текст ставится чёрным, иначе белым. Это число — вычислимая граница, по которую обе пары (чёрный сверху и белый сверху) дают контраст не ниже AA 4.5:1. В самом стандарте число не записано, но оно повторяется во всех серьёзных реализациях — от Material до утилит в Tailwind.
Почему нельзя просто усреднить RGB
Первая мысль, которая приходит в голову — взять полусумму трёх компонент (R + G + B) / 3: если получилось больше 128 (середина диапазона 0–255), считаем фон светлым, ставим чёрный текст. Иначе — белый. Просто, но не работает. Конкретные провалы:
- #ffff00 (лимонно-жёлтый): среднее 170, формула предлагает чёрный — и угадывает. Повезло.
- #00ff00 (чистый зелёный): среднее 85, формула просит белый. На самом деле чистый зелёный воспринимается глазом как очень яркий, и на нём гораздо лучше читается чёрный.
- #0000ff (чистый синий): среднее 85, формула просит белый — и тут угадывает, синий действительно тёмный.
- #00ffff (бирюзовый): среднее 170, формула просит чёрный — правильно, но скорее случайно.
Картина перестаёт быть случайной, если посмотреть, как тот же глаз видит чистый красный, зелёный и синий по отдельности при одинаковом численном значении компонента (255). Зелёный воспринимается примерно в 3.5 раза ярче синего и в 3.4 раза ярче красного. То есть в матрице человеческого восприятия зелёный канал доминирует, и просто усреднять три байта — всё равно что считать «средний возраст» группы людей, в которой один человек стоит как 3.5 других.
WCAG поэтому использует не сырые байты, а линейную RGB и взвешенную сумму с коэффициентами 0.2126 для красного, 0.7152 для зелёного и 0.0722 для синего — они описывают чувствительность глаза по стандарту sRGB. Сумма коэффициентов равна 1, поэтому L всегда в диапазоне 0–1. Перед взвешенным сложением каждый компонент проходит через гамма-коррекцию: маленькие значения почти линейны, большие — растягиваются по степенной кривой:
// 0..255 → 0..1
n = channel / 255
// гамма-коррекция sRGB
linear = (n <= 0.04045)
? n / 12.92
: ((n + 0.055) / 1.055) ** 2.4
// итоговая яркость
L = 0.2126 * R_lin + 0.7152 * G_lin + 0.0722 * B_lin
Полученное L лежит в диапазоне 0–1 и уже отражает воспринимаемую яркость, а не позицию пикселя в цветовом кубе. Близкая идея, но через цветовое пространство HSL, разбиралась раньше в материале про использование HSL-цвета в CSS — там lightness тоже даёт интуитивно понятное число, но менее точное по отношению к WCAG.
Функция на JavaScript
Соберём всё в одну функцию. На входе hex вида #RRGGBB, на выходе либо #000, либо #fff:
const SRGB_THRESHOLD = 0.04045;
const LUMA_WEIGHTS = [0.2126, 0.7152, 0.0722];
const DECISION_POINT = 0.179;
function readableTextColor(hex) {
const clean = hex.replace('#', '');
const channels = [0, 2, 4].map(i =>
parseInt(clean.slice(i, i + 2), 16) / 255
);
const linear = channels.map(c =>
c <= SRGB_THRESHOLD
? c / 12.92
: Math.pow((c + 0.055) / 1.055, 2.4)
);
const luminance = linear.reduce(
(sum, c, i) => sum + c * LUMA_WEIGHTS[i],
0
);
return luminance > DECISION_POINT ? '#000' : '#fff';
}
По шагам: replace убирает решётку, если она пришла; map по индексам [0, 2, 4] вытаскивает три пары символов и сразу нормализует к 0–1; следующий map применяет гамма-коррекцию к каждому каналу; reduce взвешенно суммирует. Единственное число, которое здесь «магическое» — 0.179, и его смысл уже описан выше.
Что функция возвращает на типовых входах:
readableTextColor('#ffffff'); // '#000' — белый фон, чёрный текст
readableTextColor('#000000'); // '#fff' — чёрный фон, белый текст
readableTextColor('#facc15'); // '#000' — жёлтый: L ≈ 0.69, чёрный
readableTextColor('#1d4ed8'); // '#fff' — насыщенный синий: L ≈ 0.07, белый
readableTextColor('#22c55e'); // '#000' — зелёный: L ≈ 0.48, чёрный
readableTextColor('#7c3aed'); // '#fff' — фиолетовый: L ≈ 0.10, белый
readableTextColor('#a3a3a3'); // '#000' — нейтральный серый чуть выше порога
readableTextColor('#737373'); // '#fff' — тот же серый чуть ниже порога
Видно, что переход случается на серых в диапазоне #737373 … #7f7f7f — именно там лежит L = 0.179. На цветных фонах граница смещена в сторону тёмных тонов из-за веса зелёного канала: умеренно-зелёный фон уже хочет чёрный текст, а такой же по «ощущению» синий — ещё белый.
Граничные случаи
Минимальная функция выше предполагает строго шестизначный hex. В реальном коде такой вход не гарантирован — форма редактора и API могут возвращать другие форматы. Что приходится учитывать дополнительно:
- Короткий hex вида #abc — раскрывается удвоением каждого символа: #abc → #aabbcc, #f00 → #ff0000. Дёшево лечится регуляркой на входе:
if (clean.length === 3) { clean = clean.split('').map(c => c + c).join(''); } - Форматы rgb() и hsl() — либо писать отдельный парсер, либо разово прогнать значение через сам браузер: создаём скрытый элемент, выставляем ему style.color и читаем getComputedStyle — на выходе всегда будет нормализованный rgb(r, g, b), из которого hex собирается одной строкой.
- Именованные цвета (tomato, slateblue, papayawhip) — CSS знает их 147 штук. Вшивать таблицу в собственный код не стоит, тот же приём с getComputedStyle переводит любое имя в hex.
- Альфа-канал — самый коварный случай. Если фон полупрозрачный (rgba(255, 200, 0, 0.5)), реальный цвет зависит от того, что под ним: на белой странице это будет один оттенок, на тёмной — другой. Универсального ответа нет: либо смешиваем заранее с цветом фона страницы, либо принудительно делаем фон бейджа непрозрачным, либо принимаем альфу как часть входа и смешиваем явно (premultiplied-формулой).
Когда не писать это руками
JavaScript здесь нужен только потому, что фон неизвестен на этапе сборки CSS. Если все возможные цвета элемента известны и их немного (4–12 фирменных оттенков), проще завести для каждого пару CSS-переменных — цвет фона и заранее подобранный к нему цвет текста — и переключать их вместе:
:root {
--status-success-bg: #16a34a;
--status-success-on: #ffffff;
--status-warning-bg: #facc15;
--status-warning-on: #1f2937;
--status-danger-bg: #dc2626;
--status-danger-on: #ffffff;
}
.badge {
background: var(--status-bg);
color: var(--status-on);
}
.badge--success { --status-bg: var(--status-success-bg); --status-on: var(--status-success-on); }
.badge--warning { --status-bg: var(--status-warning-bg); --status-on: var(--status-warning-on); }
.badge--danger { --status-bg: var(--status-danger-bg); --status-on: var(--status-danger-on); }
Именно так устроены design-токены в Material Design и Tailwind — на каждый фоновый цвет в палитре (primary-500, amber-300, и т. д.) дизайнер заранее посчитал и прописал контрастный цвет текста (on-primary, amber-900). Это надёжнее и быстрее любой JS-функции: ничего не считается в рантайме, цвет проходит ревью дизайнера, а не округление с плавающей точкой. Подробнее про CSS-переменные — в отдельной статье.
Со стороны CSS приходит и встроенная альтернатива — функция contrast-color(). Браузер сам смотрит на цвет фона и подставляет в текст контрастный цвет:
.badge {
background: var(--status-color);
color: contrast-color(var(--status-color));
}
Функция появилась в стабильных браузерах только в начале 2026 года, поэтому в продакшене её пока используют через прогрессивное улучшение: основной слой стилей задаёт color через JS-функцию выше, а @supports (color: contrast-color(red)) сверху переключает на нативное вычисление.
Связь с настройками пользователя
Контраст «по умолчанию» — не единственный сценарий. В операционных системах есть отдельная настройка повышенного контраста: пользователь сообщает, что обычной читаемости ему не хватает (плохо видит, работает на солнце, использует e-ink). В Windows эта опция называется «Высокая контрастность», в macOS — «Increase contrast», в Android — «High contrast text». Браузер пробрасывает эту настройку в CSS через медиа-запрос prefers-contrast:
@media (prefers-contrast: more) {
.badge {
/* пара цветов c контрастом 7:1, не 4.5:1 */
color: #000;
background: #ffeb3b;
outline: 2px solid #000;
}
}
У запроса три значащих значения: no-preference (пользователь ничего не выбрал), more (хочет повышенный контраст) и less (хочет наоборот пониженный — редкий случай для тех, кому ярко-контрастные интерфейсы вызывают усталость или мигрень). В подавляющем большинстве задач достаточно реагировать только на more и выдавать пары с контрастом не ниже AAA 7:1 вместо AA 4.5:1. Идея здесь та же, что у prefers-color-scheme: запрос соблюдает выбор пользователя, не пытаясь его перебить. Подробный разбор смежного медиа-запроса — в материале про prefers-color-scheme и тёмную тему.
Итог
Если фон элемента известен только в рантайме, читаемый цвет шрифта вычисляется в две строки: гамма-корректируем каждый канал, складываем со стандартными весами и сравниваем с порогом 0.179. Это даёт надёжный выбор между #000 и #fff для произвольного входного цвета и совпадает с уровнем контраста AA по WCAG. Когда поддержка contrast-color() расползётся на минимально приемлемое покрытие, JS-функция переедет в фоллбек через @supports; до тех пор она — рабочее решение и стоит примерно одиннадцать строк.
Комментарии (0)