Меняем цвет шрифта с помощью css в зависимости от фона

9349

Меняем цвет шрифта с помощью css в зависимости от фона

Вы когда-нибудь ловили себя на мысли «Я могу это сделать на одном CSS!», когда кто-то демонстрировал свои JavaScript-бицепсы? Именно это я чувствовал, когда смотрел Dag-Inge Aas & Ida Aalen talk at CSSconf EU 2018. Ребята из Норвегии, где WCAG-стандарты доступности не просто хорошая практика, а обязательное по закону условие. Они разрабатывали новую фичу для того, чтобы пользователь мог изменять цветовую тему сайта, и столкнулись с проблемой автоматической подстройки цвета шрифта на основе выбранного цвета фона. Если фон темный, было бы идеально иметь белый текст для контраста, но что если будет выбран светлый фон? Текст с ним просто сольется.

Они элегантно решили проблему с помощью npm-пакета «color», добавления условных рамок и автоматического расчета дополнительного цвета. Но они сделали это на JavaScript. А вот мое альтернативное CSS-решение.

Вызов

Я поставил перед собой такие задачи:

  • изменять color на белый или черный в зависимости от фона;
  • ту же логику применять для рамок, используя более темный вариант цвета фона кнопки для улучшения ее видимости, только если фон очень светлый;
  • автоматически создавать дополнительный цвет, изменяя параметр hue на 60° по цветовому кругу.

Работа с HSL-форматом и CSS-переменными

Самый простой подход, который я cмог придумать для этого, подразумевает использование HSL-формата цвета. При этом каждый параметр является CSS-переменной, что позволяет очень просто определять светлоту (lightness) и использовать ее в условиях.

:root {
  --hue: 220;
  --sat: 100;
  --light: 81;
}
 
.btn {
  background: hsl(var(--hue), calc(var(--sat) * 1%), calc(var(--light) * 1%));
}

Теперь мы сможем изменять цвет фона прямо в рантайме, используя CSS-переменные и условия if/else.

Подождите… но ведь в CSS нет условий if/else… или есть?

Введение в условные выражения CSS

С того момента, как в CSS появились переменные, можно говорить и об условных выражениях. Ну, некоторой их разновидности.

Этот трюк основан на том факте, что некоторые свойства в CSS имеют минимальное и максимальное значение. Например, прозрачность. Валидные значения находятся в диапазоне 0-1. Но если вы объявим opacity: 2, или 3, или даже 1000 это будет интерпретироваться как 1. А отрицательные значения превратятся в 0.

.something {
  opacity: -2; /* будет 0, полная прозрачность */
  opacity: -1; /* тоже 0 */
  opacity: 2; /* будет 1, непрозрачность */
  opacity: 100; /* тоже 1 */
}

Применение фокуса к цвету текста

Параметр lightness в HSL-декларации ведет себя точно так же, превращая все отрицательные значения в 0 (черный цвет, независимо от значений hue и saturation), а все значения больше 100%, соответственно, в 100% (всегда белый).

Таким образом, можно объявить цвет, вычесть сколько нужно из параметра светлоты и умножить на 100%, чтобы получить итоговое значение: либо меньше ноля, либо больше 100%. Поскольку для белого цвета нужны отрицательные значения, а для черного положительные, полученный результат нужно инвертировать, умножив на -1.

:root {
  --light: 80;
  /* Пороговое значение, отделяющее "светлые" цвета, от "темных". Рекомендуется 50 - 70 */
  --threshold: 60;
}
 
.btn {
  /* Если значение lightness меньше порогового, оно превратится в белый,
     если больше - в черный */
  --switch: calc((var(--light) - var(--threshold)) * -100%);
  color: hsl(0, 0%, var(--switch));
}

Давайте пройдемся по этому коду.

  • Мы начинаем со значения светлоты 80 и устанавливаем порог на 60.
  • Вычитаем одно из другого и получаем 20.
  • Умножаем на -100%. Итоговый результат равен -2000%, что преобразуется в 0%.

Наш фон светлее порогового значения, поэтому мы считаем его «светлым» и используем черный цвет для текста.

Если бы переменная --light имела значение 20, то в результате вычислений мы получили бы 4000%, то есть 100%. Для темного фона — белый текст.

Создание рамки по условию

Когда фон элемента становится слишком светлым, он может потеряться на белом фоне. Для лучшего пользовательского опыта при очень светлых цветах мы будем устанавливать рамку такого же цвета как фон, только более темного.

Меняем цвет шрифта с помощью css в зависимости от фона
Меняем цвет шрифта с помощью css в зависимости от фона

Используем ту же технику для установки альфа-канала. Настроим нужный цвет и сделаем его или полностью прозрачным, или непрозрачным.

:root {
  /* (...) */
  --light: 85;
  --border-threshold: 80;
}
 
.btn {
  /* установить border-color на 30% темнее, чем цвет фона */
  --border-light: calc(var(--light) * 0.7%);
  --border-alpha: calc((var(--light) - var(--border-threshold)) * 10);
  
  border: .1em solid hsla(var(--hue), calc(var(--sat) * 1%), var(--border-light), var(--border-alpha));
}

Если оттенок равен 0, а насыщенность 100%, для фона со светлотой более 80% рамка будет непрозрачной красной, а для более темного фона — полностью прозрачной (как будто ее нет).

Установка дополнительного цвета со смещением на 60°

Это, вероятно, самое простое. Можно использовать один из двух способов:

  1. filter: hue-rotate(60). Это первое решение, которое приходит в голову, но оно не самое лучшее, потому что может повлиять на дочерние элементы. Если необходимо, его эффект можно отменить противоположным фильтром.
  2. HSL hue + 60. Просто добавить 60 к hue. Значение не лимитировано числом 360, оно спокойно прокручивается по цветовому кругу. Таким образом 400deg=40deg480deg=120deg и так далее.

Учитывая это, мы можем добавить класс-модификатор для наших дополнительных элементов, который добавляет 60 к значению оттенка. Поскольку переменные в CSS не могу самоизменяться (т. е. нет такой вещи, как --hue: calc (var (--hue) + 60)), нужно добавить новую вспомогательную переменную для манипуляции с оттенком.

.btn {
  /* (...) */
  --h: var(--hue);
  background: hsl(var(--h), calc(var(--sat) * 1%), calc(var(--light) * 1%));
  border:.1em solid hsla(var(--h), calc(var(--sat) * 1%), var(--border-light), var(--border-alpha));
}

А затем переопределить ее в классе-модификаторе:

.btn--secondary {
  --h: calc(var(--hue) + 60);
}

Самое лучшее в этом подходе — это автоматический пересчет и применение всех значений.

Собрав все вместе, мы получаем решение для всех трех поставленных задач на чистом CSS.

See the Pen
CSS Automatic switch font color depending on element background…. FAIL
by Facundo Corradini (@facundocorradini)
on CodePen.

Но оно не работает. Некоторые значения оттенка довольно проблемные (особенно желтые и голубые), так как они отображаются ярче, чем остальные (красные и синие), хотя параметр яркости у них одинаковый. В результате некоторые цвета расцениваются как темные и подставляется белый текст, но они для этого слишком яркие.

Что, во имя CSS, с этим делать?

Восприятие яркости

Яркость в нашем восприятии не соответствует HSL-светлоте. Но, к счастью, мы можем ее измерить и адаптировать код должным образом, чтобы он также учитывал оттенок цвета.

Для этого присвоим каждому из трех основных цветов коэффициент восприятия человеческим глазом. Обычно это называется luma.

Существует несколько методов, вот два самых популярных:

  • sRGB Luma (ITU Rec. 709):
    L = (red * 0.2126 + green * 0.7152 + blue * 0.0722) / 255
  • W3C-метод (working draft):
    L = (red * 0.299 + green * 0.587 + blue * 0.114) / 255

Скорректированные расчеты

Первое очевидное последствие использование подхода с коррекцией яркости заключается в том, что мы не можем использовать формат HSL, так как CSS не может конвертировать его в RGB самостоятельно.

Поэтому придется перейти на RGB-формат для фона, вычислить параметр luma с помощью любого метода и использовать его в объявлении цвета (которое останется в HSL-формате).

:root {
  /* цвет темы в RGB формате */
  --red: 200;
  --green: 60;
  --blue: 255;
  /* граничное значение для "светлых" цветов
     рекомендуется 0.5 - 0.6 */
  --threshold: 0.5;
  /* граничное значение для отображения рамки
     рекомендуется 0.8+ */
  --border-threshold: 0.8;
}
 
.btn {
  /* фон для основного класса */
  background: rgb(var(--red), var(--green), var(--blue));
 
  /* расчет воспринимаемой яркости по методу sRGB Luma 
  Luma = (red * 0.2126 + green * 0.7152 + blue * 0.0722) / 255 */
  --r: calc(var(--red) * 0.2126);
  --g: calc(var(--green) * 0.7152);
  --b: calc(var(--blue) * 0.0722);
  --sum: calc(var(--r) + var(--g) + var(--b));
  --perceived-lightness: calc(var(--sum) / 255);
  
  /* черный или белый цвет */
  color: hsl(0, 0%, calc((var(--perceived-lightness) - var(--threshold)) * -10000000%)); 
}

Для рамок нужно использовать RGBA и с помощью альфа-канала установить прозрачность. Более темный оттенок получается вычитанием 50 из каждого канала.

В WebKit на iOS есть странная ошибка: если в кратком объявлении свойства border в формате RGBA использовать переменные, рамка отображается черной. Чтобы этого избежать, нужно использовать развернутое объявление.

.btn {
  /* (...) */
  /* темная рамка, если светлота больше, чем граничное значение */ 
  --border-alpha: calc((var(--perceived-lightness) - var(--border-threshold)) * 100);
 
  border-width: .2em;
  border-style: solid;
  border-color: rgba(calc(var(--red) - 50), calc(var(--green) - 50), calc(var(--blue) - 50), var(--border-alpha));  
}

Так как мы больше не используем HSL для фона, то для получение дополнительного цвета воспользуемся фильтром:

.btn--secondary {
  filter: hue-rotate(60deg);
}

Это не самый лучший подход. Помимо потенциальных проблем с дочерними элементами это означает, что переключение черного и белого цвета и видимость рамки на дополнительном элементе будет зависеть от оттенка основного элемента, а не от его собственного. Но реализация JavaScript имеет ту же проблему, поэтому это достаточно близкое решение.

И вот окончательный вариант:

See the Pen
CSS Automatic WCAG contrast font-color depending on element background
by Facundo Corradini (@facundocorradini)
on CodePen.

Решение на чистом CSS позволяет добиться того же эффекта, что и JavaScript, но при этом требует меньшего объема кода.

Поддержка браузерами

IE не поддерживает CSS-переменные. Edge не поддерживает абсурдные декларации вроде opacity: 3 и просто отбрасывает их как ошибочные.

Другие современные браузеры должны поддерживать это решение.

источник