Когда дизайн известен заранее, цвет текста подбирается глазом: бренд-цвет на палитре, к нему сверху чёрный или белый, дизайнер всё проверил. Сложнее, когда фон элемента приходит из данных — в бейдже статуса с настраиваемой заливкой, в теге, который автор поста выбрал сам, в столбике диаграммы с цветом из категории. Один 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));
}
Поддержка браузерами
chrome
Chrome
147
firefox
Firefox
146
edge
Edge
147
safari
Safari
26.0
opera
Opera
131

Функция появилась в стабильных браузерах только в начале 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;
  }
}
Поддержка браузерами
chrome
Chrome
96
firefox
Firefox
101
edge
Edge
96
safari
Safari
14.1
opera
Opera
82

У запроса три значащих значения: 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; до тех пор она — рабочее решение и стоит примерно одиннадцать строк.