Генерация цветов текста с помощью CSS
Можем ли мы эмулировать будущую функцию CSS contrast-color()
с помощью функций CSS, которые уже широко распространены? И если да, то каковы компромиссы и как их лучше всего сбалансировать?
Относительные цвета
Функция Relative Color Syntax (RCS) позволяет авторам CSS получать новый цвет из существующего значения цвета, выполняя произвольные математические вычисления над цветовыми компонентами в любом поддерживаемом цветовом пространстве:
--color-lighter: hsl(from var(--color) h s calc(l * 1.2));
--color-lighterer: oklch(from var(--color) calc(l + 0.2) c h);
--color-alpha-50: oklab(from var(--color) l a b / 50%);
Идея заключалась в том, что, разрешая операции более низкого уровня, они предоставляют авторам гибкость в получении цветовых вариаций, давая нам больше времени, чтобы выяснить, какими должны быть соответствующие примитивы более высокого уровня.
По состоянию на май 2024 года RCS доступен во всех браузерах, кроме Firefox, но учитывая. Большинство учебных пособий по Relative Colors вращаются вокруг основных вариантов использования: создание оттенков и теней или других цветовых вариаций путем увеличения или уменьшения определенного цветового компонента и/или переопределения цветового компонента с фиксированным значением, как в примере выше. Хотя это и устраняет некоторые очень распространенные болевые точки, это лишь поверхностное представление о возможностях RCS. В этой статье рассматривается более продвинутый вариант использования, в надежде, что он послужит толчком к более творческому использованию RCS в реальных условиях.
CSS-функция contrast-color()
Одна из давних проблем CSS заключается в том, что невозможно автоматически указать цвет текста, который гарантированно будет читаемым на произвольном фоне, например, белый на более темных цветах и черный на более светлых.
Зачем это нужно? Основной вариант использования — когда цвета находятся вне контроля автора CSS. Это включает в себя:
- Пользовательские цвета: пример, с которым вы, вероятно, знакомы — метки GitHub. Подумайте о том, как вы выбираете произвольный цвет при создании метки, а GitHub автоматически выбирает цвет текста — часто неудачно (чуть позже мы увидим, почему).
- Цвета определены другим разработчиком: например, вы пишете веб-компонент, который поддерживает определенные переменные CSS для стилизации. Вы можете потребовать отдельные переменные для текста и фона, но это снизит удобство использования вашего веб-компонента, усложняя его использование. Разве не было бы здорово, если бы можно было просто использовать разумное значение по умолчанию, которое можно, но редко нужно переопределять?
- Цвета определяются внешней системой дизайна: такой как Open Props, Material Design или даже Tailwind.
Даже в кодовой базе, где каждая строка кода CSS контролируется одним автором, сокращение связей может улучшить модульность и облегчить повторное использование кода.
Хорошей новостью является то, что это больше не будет проблемой. Функция color-contrast()
CSS была разработана именно для этого. Это не новость, возможно, вы слышали о нем раньше, о более раннем названии. Недавно пришло соглашение о том, чтобы свести его к MVP, который устраняет наиболее заметные болевые точки и действительно может быть выпущен в ближайшее время, поскольку он обходит некоторые очень сложные проектные решения, которые привели к остановке полномасштабной функции.
Использование будет выглядеть следующим образом:
background: var(--color);
color: contrast-color(var(--color));
Но 2 года — это все равно большой срок (и нет никаких гарантий, что он не будет больше). Возможно, это некрасиво, но есть способ эмулировать (или что-то близкое к этому) с помощью Relative Colors.
Использование RCS для автоматического вычисления контрастного цвета текста
Далее мы будем использовать цветовое пространство OKLCh, которое является наиболее единообразным для восприятия полярным цветовым пространством, поддерживаемым CSS.
- Перцептивно однородное цветовое пространство: цветовое пространство, в котором евклидово расстояние между двумя цветами пропорционально их воспринимаемой разнице. Пространства RGB (и их полярные формы, HSL, HSV, HSB, HWB и т. д.) обычно не являются единообразными по восприятию. Примеры единообразных по восприятию цветовых пространств включают Lab, LCH, OkLab и OkLCh.
- Полярное цветовое пространство: цветовое пространство, в котором цвета представлены в виде углового оттенка (который определяет «основной» цвет, например, красный, желтый, зеленый, синий и т. д.) и двух компонентов, которые управляют точным оттенком этого оттенка (обычно некоторые вариант красочности и яркости).
Предположим, существует значение яркости, выше которого черный текст гарантированно будет читаемым независимо от цветности и оттенка, а ниже которого белый текст гарантированно будет читаемым. Мы проверим это предположение позже, а пока давайте примем его как должное. В оставшейся части статьи мы будем называть это значение порогом и обозначать его как L порог.
В следующем разделе мы вычислим это значение более строго (и докажем, что оно действительно существует!), а пока давайте использовать 0.7
(70%). Мы можем присвоить его переменной, чтобы упростить настройку:
--l-threshold: 0.7;
В большинстве реальных примеров RCS используются calc()
простые операции сложения и умножения. Однако любая математическая функция, поддерживаемая CSS, на самом деле является честной игрой, включая clamp()
тригонометрические функции и многие другие. Например, если вы хотите создать более светлый оттенок основного цвета с помощью RCS, вы можете сделать что-то вроде этого:
background: oklch(from var(--color) 90% clamp(0, c, 0.1) h);
Давайте работать в обратном направлении от желаемого результата. Мы хотим придумать выражение, состоящее из широко поддерживаемых математических функций CSS, которое будет возвращать 1, если пороговое значение L ≤ L, и 0 в противном случае. Если бы мы могли написать такое выражение, мы могли бы затем использовать это значение как яркость нового цвета:
--l: /* ??? */;
color: oklch(var(--l) 0 0);
Широко поддерживаются следующие математические функции CSS:
calc()
min()
,max()
,clamp()
- Тригонометрические функции
- Показательные функции
Как мы могли бы упростить задачу? Один из способов — расслабить то, что наше выражение должно вернуть. На самом деле нам не нужен точный 0 или 1. Если нам удастся найти выражение, которое даст нам 0, когда L > L порог, и 1, когда L ≤ L порог, мы можем просто использовать clamp(0, /* expression */, 1)
для получения желаемого результата.
Одной из идей было бы использовать отношения, поскольку они обладают замечательным свойством: они > 1, если числитель больше знаменателя, и ≤ 1 в противном случае.
Соотношение л/лпорог > 1 для L ≤ L_порог и < 1, когда L > L_порог. Это значит, что (L/L_порог − 1) будет отрицательным числом для L > L_порог и положительным числом для L > L_порог. Затем все, что нам нужно сделать, это умножить это выражение на огромное число, чтобы положительное число гарантированно было больше 1.
Если сложить все это вместе, то это выглядит так:
--l-threshold: 0.7;
--l: clamp(0, (var(--l-threshold) / l - 1) * infinity, 1);
color: oklch(from var(--color) var(--l) 0 h);
Единственное беспокойство может заключаться в том, что если L приблизится достаточно близко к порогу, мы сможем получить число от 0 до 1, но в моих экспериментах этого никогда не происходило, предположительно, поскольку точность конечна.
Резервный вариант для браузеров, не поддерживающих RCS.
Последняя часть головоломки — предоставить запасной вариант для браузеров, не поддерживающих RCS. Мы можем использовать @supports
любое свойство цвета и любое относительное значение цвета в качестве теста, например:
.contrast-color {
/* Fallback */
background: hsl(0 0 0 / 50%);
color: white;
@supports (color: oklch(from red l c h)) {
--l: clamp(0, (var(--l-threshold) / l - 1) * infinity, 1);
color: oklch(from var(--color) var(--l) 0 h);
background: none;
}
}
Чтобы убедиться, что все работает в неподдерживаемых браузерах, даже если они менее привлекательны, можно использовать некоторые запасные идеи:
- Белый или полупрозрачный белый фон с черным текстом или наоборот.
-webkit-text-stroke
с цветом, противоположным цвету текста. Это лучше работает с более жирным текстом, поскольку половина контура находится внутри букв.- Многие
text-shadow
значения имеют цвет, противоположный цвету текста. Это лучше работает с более тонким текстом, поскольку он рисуется позади текста.
Существует ли этот мифический порог L на самом деле?
В предыдущем разделе мы сделали довольно серьезное предположение: существует значение яркости (порог L), выше которого черный текст гарантированно будет читаемым независимо от цветности и оттенка, а ниже которого белый текст гарантированно будет читаемым независимо от цветности и оттенка. Но существует ли такая ценность? Пришло время проверить это утверждение.
Когда люди впервые слышат о воспринимаемых однородных цветовых пространствах, таких как Lab, LCH или их улучшенных версиях OkLab и OKLCH, они воображают, что могут сделать вывод о контрасте между двумя цветами, просто сравнивая их значения L (яркость). К сожалению, это не так, поскольку контраст зависит не только от воспринимаемой яркости, но и от большего количества факторов. Однако, безусловно, существует значительная корреляция между значениями яркости и контрастностью.
Здесь я должен отметить, что, хотя большинство веб-дизайнеров знают об алгоритме контрастности WCAG 2.1, который является частью Руководства по обеспечению доступности веб-контента и закреплен в законодательстве во многих странах, уже много лет известно, что он выдает крайне плохие результаты. Настолько плохие, что в некоторых тестах он работает почти так же плохо, как случайный случай для любого цвета, который не очень светлый или не очень темный. Существует более новый алгоритм контрастирования, APCA, который дает гораздо лучшие результаты, но еще не является частью какого-либо стандарта или законодательства, и ранее на пути к его бесплатному доступу для публики возникали некоторые препятствия (которые, похоже, в значительной степени решены).
Так что же тогда остается веб-авторам? Как оказалось, в довольно затруднительном положении. Кажется, что лучший способ создать доступные цветовые пары прямо сейчас — это двухэтапный процесс:
- Используйте APCA, чтобы обеспечить реальную читабельность
- Соответствие отказоустойчивости: убедитесь, что результат не приводит к явному сбою WCAG 2.1.
Я провел несколько быстрых экспериментов с использованием Color.js, где я перебираю эталонный диапазон OKLCh (в общих чертах на основе гаммы P3) с шагом увеличения детализации и вычисляю диапазоны яркости для цветов, где белый был «лучшим» цветом текста (= производится с более высокой степенью детализации, контрастнее, чем черный) и наоборот. Я также рассчитываю скобки для каждого уровня (провал, AA, AAA, AAA+) как для APCA, так и для WCAG.
Затем я превратил свое исследование в интерактивную игровую площадку, где вы можете проводить те же эксперименты самостоятельно, возможно, с более узкими диапазонами, которые соответствуют вашему сценарию использования, или с более высокой степенью детализации.
Это таблица, созданная с помощью C ∈ [0, 0,4] (шаг = 0,025) и H ∈ [0, 360) (шаг = 1):
Обратите внимание, что это минимальное и максимальное значения L для каждого уровня. Например, тот факт, что белый текст может не пройти WCAG, когда L ∈ [62,4%, 100%], не означает, что каждый цвет с L > 62,4% не пройдет WCAG, просто некоторые так и делают. Таким образом, мы можем сделать значимые выводы, только инвертировав логику: поскольку все ошибки белого текста имеют L ∈ [62,4%, 100%], из этого логически следует, что если L < 62,4%, белый текст пройдет WCAG независимо от того, что цвет есть.
Применяя эту логику ко всем диапазонам, мы можем получить аналогичные гарантии для многих из этих скобок:
Возможно, вы заметили, что в целом WCAG имеет много ложных негативов вокруг белого текста и имеет тенденцию устанавливать порог яркости намного ниже, чем APCA. Это известная проблема алгоритма WCAG.
Поэтому, чтобы наилучшим образом сбалансировать читаемость и соответствие требованиям, мы должны использовать самый высокий порог, который нам может сойти с рук. Это означает:
- Если прохождение WCAG является обязательным, максимальный порог, который мы можем использовать, составляет 62,3%.
- Если нас беспокоит только фактическая читаемость, мы можем смело игнорировать WCAG и выбрать порог где-то между 68,7% и 71,6%, например 70%.
Избегайте цветов с пометкой «P3+», «PP» или «PP+», поскольку они почти наверняка выходят за пределы гаммы вашего экрана, а браузеры в настоящее время неправильно отображают гамму, поэтому визуальный результат будет неверным.
Обратите внимание: если ваш фактический цвет более ограничен (например, подмножество оттенков или цветности или определенная гамма), вы можете лучше сбалансировать эти компромиссы, используя другой порог. Проведите эксперимент самостоятельно с реальным диапазоном цветов и узнайте!
Вот несколько примеров более узких диапазонов, которые я пробовал, и самого высокого порога, который все еще соответствует WCAG 2.1:
Особенно интересно, что порог повышен до 64,5% за счет простого игнорирования цветов, которые фактически не отображаются на современных экранах. Итак, предполагая, что браузеры отдают приоритет сохранению яркости при отображении гаммы, мы могли бы использовать 64,5% и при этом гарантировать соответствие WCAG.
Вы даже можете превратить это в служебный класс, который можно комбинировать с различными пороговыми значениями:
.contrast-color {
--l: clamp(0, (var(--l-threshold, 0.623) / l - 1) * infinity, 1);
color: oklch(from var(--color) var(--l) 0 h);
}
.pink {
--l-threshold: 0.67;
}
Заключение
Собрав все это вместе, включая резервный вариант, а также «переход вперед», использующий contrast-color()
, служебный класс может выглядеть следующим образом:
.contrast-color {
/* Fallback for browsers that don't support RCS */
color: white;
text-shadow: 0 0 .05em black, 0 0 .05em black, 0 0 .05em black, 0 0 .05em black;
@supports (color: oklch(from red l c h)) {
--l: clamp(0, (var(--l-threshold, 0.623) / l - 1) * infinity, 1);
color: oklch(from var(--color) var(--l) 0 h);
text-shadow: none;
}
@supports (color: contrast-color(red)) {
color: contrast-color(var(--color));
text-shadow: none;
}
}