Цветовое пространство OKLCH — почему его принял Tailwind v4
Откройте исходник любой дизайн-системы образца 2025 года — shadcn/ui, Radix Themes, базовую палитру Tailwind v4 — и первое, что бросается в глаза, это цвета. Не hex-коды, не тройки hsl(), а функция, о которой три года назад никто не говорил: oklch(). Tailwind v4 поставляет всю палитру по умолчанию как OKLCH-литералы. shadcn теперь генерирует темы, которые выдают OKLCH-кастом-свойства. Дизайн-система Vercel перестроилась вокруг него в 2024 году.
Это не следование моде. Есть конкретная, математическая причина, по которой каждая серьёзная дизайн-система тихо сменила цветовую модель, и стоит её увидеть — и вы уже не сможете развидеть, почему HSL всегда был неверным выбором для того, для чего мы его использовали.
Этот пост проводит через эту причину от первых принципов, заканчивается разобранной конвертацией из hex-кода в OKLCH и даёт рецепт миграции для вашей собственной палитры.
Когда цветовые пространства сломались
У дизайн-систем есть тональная задача. Кнопка при наведении становится чуть светлее, чем в состоянии покоя. Приглушённая карточка сидит на одну ступень темнее, чем поверхность вокруг. Кольцо фокуса должно быть заметно ярче нейтральной хром-зоны за ним. Делать это хорошо в масштабе требует, чтобы «светлее» и «темнее» означали одно и то же по каждому оттенку вашей палитры.
Это требование легко было игнорировать, когда палитры состояли из восьми цветов и трёх состояний. Оно стало неудобным, когда команды начали поставлять 11-ступенчатые шкалы (50–950 в конвенции Tailwind), восемь семантических цветов, светлые и тёмные варианты и брендовые акценты, которые должны были сосуществовать с системными цветами из iOS, Android и веба. Внезапно вопрос «совпадает ли наш teal-500 по светлоте с blue-500» стал реальной инженерной задачей, а не роскошью арт-дирекшна.
HSL — рабочая лошадка со времён CSS 3 — не могла на него ответить. Два цвета HSL с одинаковыми значениями L могут выглядеть драматически по-разному по воспринимаемой яркости. Чистый HSL-жёлтый при lightness: 50% выглядит куда ярче, чем чистый HSL-синий при той же светлоте. Глаз воспринимает жёлтый и синий неодинаково; HSL проектировался так, чтобы быть интуитивным в пипетках-пикерах, а не перцептивно согласованным для шкал. К 2023 году каждая дизайн-система, перешагнувшая горстку цветов, латала это самописными скриптами смешивания или ручными переопределениями.
Нам нужна была цветовая модель, в которой L действительно означал бы «воспринимаемая яркость, которую сообщил бы человек», и где поворот оттенка или снижение насыщенности не сдвигало бы яркость как невидимый побочный эффект. Эта модель существовала в академической науке о цвете — просто она ещё не добралась до CSS.
Проблема HSL, конкретно
Бросьте эти строки в браузер и посмотрите на них рядом:
.a { background: hsl(60 100% 50%); } /* yellow */
.b { background: hsl(240 100% 50%); } /* blue */
.c { background: hsl(120 100% 50%); } /* green */
У всех трёх L: 50%. Ни один не выглядит так, будто у них одинаковая светлота. Жёлтый почти жжёт; синий читается почти как чёрный на белой странице; зелёный сидит между ними. Если построить hover-состояние, добавив 10% к L, hover жёлтого едва различим, тогда как hover синего — драматический сдвиг. Полировка взаимодействий в итоге зависит от того, с какого оттенка дизайнер случайно начал.
Это не баг HSL. HSL спроектировали в 1978 году для цветовых пикеров «по номерам», где пользователи манипулировали оттенком, насыщенностью и «светлотой» — определённой как (max(R,G,B) + min(R,G,B)) / 2 — чтобы подобрать цвет. В этой математике нет понятия человеческого восприятия. Светлота в HSL — это геометрическая середина каналов sRGB, не более того.
CIE — международная организация по стандартизации в колориметрии — знала об этой проблеме с 1970-х. Они опубликовали два перцептивно равномерных пространства, CIELAB и CIELUV, в которых светлота определялась как нечто более близкое к тому, что делает человеческое зрение на самом деле. К 1990-м CIE LAB стал стандартом в полиграфии, фотографии и управлении цветом. Но конвертация в RGB у него корявая, и CSS так и не принял его широко. Веб-разработчики продолжали пользоваться HSL не потому, что он был верен, а потому, что он был под рукой.
CIE LAB / LCH: академическое решение со своими проблемами
CIELAB берёт XYZ-трёхстимульное значение (модель того, как колбочки человека отвечают на свет) и прогоняет его через кубический корень и двумерный поворот, чтобы получить три канала: L* (светлота, 0–100), a* (зелёный ↔ красный) и b* (синий ↔ жёлтый). LCH — то же пространство, выраженное в полярной форме: L*, C* (chroma, расстояние от нейтрали), H* (угол оттенка).
Эти пространства перцептивно равномерны в измеримом смысле. ΔE, равная 1, — единичный шаг в любом направлении в пространстве LAB — это приблизительно наименьшая разница цветов, которую тренированный наблюдатель может различить. Полиграфические и допечатные процессы десятилетиями шли на LAB и LCH.
Так почему CSS просто не принял LCH и не пошёл дальше?
Две причины. Первая: CIE LAB калибровался под конкретное условие наблюдения (стандартный 2°-наблюдатель при освещении D50), оптимизированное под отражение от поверхностей, а не под излучающие дисплеи. На экранах его перцептивная равномерность плывёт — цвета, «одинаково яркие» в LAB, не всегда выглядят одинаково яркими на телефоне. Вторая: цветовой охват LCH неуклюж. Есть видимые цвета, которые LAB описывает хорошо, но которые лежат вне распространённых охватов дисплеев, и отображение из LCH в sRGB иногда вызывает сдвиги оттенка (ваш синий чуть пурпурнеет, когда вы уменьшаете chroma). Для работы с дизайн-системами оба этих обстоятельства — стоп-факторы.
CSS Color 4 действительно добавил lab() и lch() в 2021 году, и они работают в современных браузерах. Но для конкретной задачи построения согласованных тональных шкал на излучающих экранах сообщество продолжило искать.
OKLAB / OKLCH: озарение Оттоссона 2020 года
В декабре 2020 года Björn Ottosson — шведский инженер по цвету — опубликовал статью «A perceptual color space for image processing» («Перцептивное цветовое пространство для обработки изображений»). Статья была небольшой: три короткие матрицы, шаг кубического корня, никаких калибровочных таблиц, никаких защищённых авторским правом эталонных данных. Ottosson взял существующие цветовые модели IPT и CAM16-UCS — академические пространства с хорошими свойствами, но плохой математикой — и вывел более простое пространство, которое аппроксимировало их перцептивное поведение через обычные матричные умножения над линейно-световыми XYZ-трёхстимульными значениями.
Он назвал его OKLAB. Полярная форма — OKLCH.
Что делает OKLCH особенным — не новизна, а пригодность для задачи. Три свойства вместе:
- Светлота в OKLCH по-настоящему перцептивна. Чистый жёлтый при
L: 0.7и чистый синий приL: 0.7выглядят одинаково по яркости на калиброванном дисплее. Hover-состояния, определённые какL + 0.05, дают визуально эквивалентные сдвиги по всей палитре. - Оттенок сохраняется при изменении chroma. Если вы уменьшаете значение C у
oklch(0.7 0.2 30)доoklch(0.7 0.1 30), оттенок остаётся на месте. В LCH та же операция часто вносит заметный сдвиг оттенка. В OKLCH вы можете сплющить chroma, чтобы построить приглушённый вариант брендового цвета без того, чтобы он случайно дрейфовал в сторону другого оттенка. - Математика дешёвая. Два матричных умножения и один кубический корень. Реализуемо в 30 строках JavaScript-функции. Никаких таблиц поиска, никакой покалиброванной под устройство настройки, никаких лицензионных вопросов.
Эта комбинация и сделала OKLCH пригодным для реального CSS. W3C добавил oklch() в CSS Color 4 в 2022 году. Chrome 111 поставил его в 2023-м. К середине 2024 года каждый evergreen-браузер его поддерживал. В том же году Tailwind v4 сделал его форматом палитры по умолчанию.
Математика: разобранная конвертация HEX → OKLCH
Пройдёмся через #3b82f6 — blue-500 из Tailwind — в OKLCH. Это та же математика, которую Конвертер цветов и спок HEX в OKLCH выполняют на каждом нажатии клавиши. Понимание того, что происходит под капотом, сделает подводные камни в следующем разделе более осмысленными.
Шаг 1: HEX в sRGB. Разделите шестизначный hex на три пары и поделите каждую на 255.
const r = 0x3b / 255; // 0.231
const g = 0x82 / 255; // 0.510
const b = 0xf6 / 255; // 0.965
Это гамма-кодированные значения sRGB: значения каналов так, как их хранит ваш файл изображения, с запечённой нелинейной кривой для компенсации того, как мониторы излучают свет.
Шаг 2: sRGB в линейный sRGB. Уберите гамма-кривую, чтобы получить линейно-световые значения каналов. Стандартное кусочное преобразование из CSS Color 4 §11.2:
const linear = (v) => v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
const [lr, lg, lb] = [r, g, b].map(linear);
// lr ≈ 0.045, lg ≈ 0.220, lb ≈ 0.923
Шаг 3: Линейный sRGB в XYZ D65. Стандартное матричное умножение, определённое в CSS Color 4 §15.1:
const x = 0.4124564 * lr + 0.3575761 * lg + 0.1804375 * lb;
const y = 0.2126729 * lr + 0.7151522 * lg + 0.0721750 * lb;
const z = 0.0193339 * lr + 0.1191920 * lg + 0.9503041 * lb;
// x ≈ 0.265, y ≈ 0.231, z ≈ 0.927
Это каноническое представление XYZ-трёхстимула — форма «какие длины волн в этом цвете в терминах отклика колбочек человека».
Шаг 4: XYZ в LMS. Первая матрица Ottosson отображает XYZ в пространство фундаментальных колбочек long/medium/short, настроенное под OKLAB:
const lms = [
0.8189330101 * x + 0.3618667424 * y - 0.1288597137 * z,
0.0329845436 * x + 0.9293118715 * y + 0.0361456387 * z,
0.0482003018 * x + 0.2643662691 * y + 0.6338517070 * z,
];
Шаг 5: Извлечь кубический корень из значений LMS. Это шаг перцептивного сжатия — аналогичный кубическому корню в CIE LAB:
const lms_ = lms.map(Math.cbrt);
Шаг 6: LMS’ в OKLAB. Вторая матрица Ottosson:
const L = 0.2104542553 * lms_[0] + 0.7936177850 * lms_[1] - 0.0040720468 * lms_[2];
const a = 1.9779984951 * lms_[0] - 2.4285922050 * lms_[1] + 0.4505937099 * lms_[2];
const b_ = 0.0259040371 * lms_[0] + 0.7827717662 * lms_[1] - 0.8086757660 * lms_[2];
// L ≈ 0.629, a ≈ -0.022, b_ ≈ -0.191
Это и есть OKLAB. Канал светлоты L ≈ 0.629 — это то, что глаз воспринимает как «60% яркости» для конкретно этого синего.
Шаг 7: OKLAB в OKLCH (декартовы в полярные).
const C = Math.sqrt(a * a + b_ * b_); // 0.193
const H = (Math.atan2(b_, a) * 180 / Math.PI + 360) % 360; // 263.4
// → oklch(0.629 0.193 263.4)
Итак, #3b82f6 — это oklch(0.629 0.193 263.4). Светлота 0.629, chroma 0.193, оттенок 263.4°.
Если вы строите 50–950 шкалу из этого цвета, варьируя только L (от 0.95 вниз до 0.15 в 11 шагов), удерживая C и H фиксированными, вы получите палитру, в которой каждый оттенок визуально один и тот же по hue, равномерно угасая по светлоте. Сделайте то же самое в HSL — и тёмные оттенки уйдут в пурпур, а светлые поседеют. Вот в чём выигрыш.
Display P3 + Rec2020: почему OKLCH открывает широкий цветовой охват
OKLCH не ограничен. В отличие от HSL (запертого в sRGB) и даже LCH (откалиброванного под отражающие поверхности), у OKLCH нет неявного цветового охвата. Можно написать oklch(0.7 0.25 30) и получить яркий красный, который сидит внутри цветового объёма Display P3, но вне sRGB. На свежем iPhone или MacBook он отрисуется. На старом мониторе браузер автоматически прижмёт его к ближайшему представлению sRGB.
Это важно, потому что Apple, Samsung и W3C весь конец 2010-х выпускали железо с широким цветовым охватом. MacBook Pro 14” / 16” с mini-LED поставляется с P3 по умолчанию. iPhone 15 Pro отрисовывает Display P3 в Safari. Флагманы Android поставляются с панелями Rec2020. К 2025 году существенная доля трафика дизайн-систем приходится на железо с широким цветовым охватом, способное показывать цвета, которые HSL/sRGB просто не могут выразить.
OKLCH позволяет писать эти цвета без отдельной декларации @media (color-gamut: p3). Браузер сам обрабатывает fallback. Ваша дизайн-система получает «используй самый яркий красный, который устройство может отрисовать» из коробки.
Именно поэтому OKLCH — правильный формат для дизайн-токенов. Переменная --brand в OKLCH — это независимое от устройства описание намерения. Браузер сам разбирается, что отрисовать на любом дисплее пользователя, и ваш код переносим между CSS, SwiftUI (где Display P3 Color поддерживается нативно), Android Compose (с учётом Rec2020) и Flutter.
Tailwind v4 и революция дизайн-токенов
Tailwind v4 — выпущенный в 2024 году — стал точкой перегиба, превратившей OKLCH из исследований в индустриальный стандарт по умолчанию. Авторы Tailwind приняли три волевых решения:
- Палитра по умолчанию в OKLCH. Slate, gray, zinc, neutral, stone — каждый цвет Tailwind определён в
oklch()в исходниках. Шкалы 50–950 равномерны по воспринимаемой светлоте по построению. - Кастомные темы используют блоки
@themeс OKLCH-литералами. Брендовые цвета определяются как токеныoklch(); нижестоящие утилиты (bg-brand-500,text-brand-300) генерируются. - Никакой fallback-церемонии не требуется. Браузеры без поддержки OKLCH — ниже задокументированного бейзлайна Tailwind v4.
Это последнее решение и сделало переход безопасным от выстрелов в ногу. Ещё в 2023 году дизайнерам приходилось поставлять обе версии каждого цвета — oklch() и hsl(), — чтобы старые версии Safari не ломались. С Tailwind v4 бейзлайн — браузеры 2023 года и новее, и OKLCH работает везде.
Генератор тем shadcn/ui следует тому же паттерну: введите свой брендовый цвет — получите OKLCH-шкалу на выходе. Дизайн-система Vercel использует OKLCH для своих семантических цветов. Цветовые шкалы Radix Themes определены в OKLCH. Сообщество сошлось.
Практическая миграция: HEX-палитра → OKLCH-палитра
Если у вас сегодня палитра на hex, миграция механическая. Вот рецепт:
1. Определитесь со структурой шкалы. 50–950 (11 ступеней) Tailwind — де-факто стандарт и стоит ему следовать, если у вас нет конкретной причины делать иначе. Ступени на L = 0.97, 0.93, 0.86, 0.76, 0.63, 0.50, 0.42, 0.34, 0.26, 0.18, 0.10 дают плавную перцептивную шкалу.
2. Сконвертируйте свой брендовый hex в OKLCH. Используйте инструмент Конвертер цветов или HEX в OKLCH. Вы получите тройку вроде oklch(0.629 0.193 263.4). Заметьте значение H — это ваш брендовый оттенок.
3. Удерживайте C и H постоянными; варьируйте L. Постройте шкалу, выдавая:
--brand-50: oklch(0.97 0.193 263.4);
--brand-100: oklch(0.93 0.193 263.4);
...
--brand-950: oklch(0.10 0.193 263.4);
4. Подстройте крайние ступени. При очень низких L (≤ 0.20) и очень высоких L (≥ 0.95) высокие значения chroma выпадают за пределы sRGB. Уменьшите C для этих ступеней или примите автоматическое прижатие браузера. Дефолты Tailwind уменьшают chroma в обе крайности — скопируйте этот паттерн.
5. Определите семантические алиасы. --surface: var(--brand-50), --surface-elevated: var(--brand-100), --text-primary: var(--brand-900) и так далее. Теперь ваши дизайн-токены читаются как намерение, а не как цвета.
6. Проверьте контраст. Используйте APCA Lc или коэффициенты контраста WCAG 2, чтобы убедиться, что каждая пара --text-* против каждой --surface-* соответствует вашей планке по доступности. Поскольку L в OKLCH перцептивна, математика контраста более надёжна, чем была бы в HSL.
Команда, проводящая эту миграцию на легаси-палитре из 60 цветов, обычно приходит к меньшей, более равномерной OKLCH-палитре из 30–40 токенов за один вечер. Новая палитра поставляется меньше, генерирует меньше CSS и выдаёт визуально лучшую тональную динамику в hover- и disabled-состояниях без какой-либо дополнительной настройки.
Подводные камни и как с ними обращаться
Несколько вещей, которые стоит знать на входе:
Предупреждения о выходе за пределы цветового охвата. Некоторые значения OKLCH выпадают за пределы Display P3 или sRGB. Современные браузеры автоматически прижимают к ближайшему валидному цвету, но это прижатие с потерями: ваш oklch(0.7 0.25 30) может отрисоваться чуть менее насыщенным, чем вы его написали. Инструменты вроде ряда цветового охвата Конвертера цветов подскажут, безопасен ли ваш цвет в sRGB, P3 или Rec2020, и предложат прижатие к sRGB в один клик, чтобы то, что вы пишете, и было тем, что видите.
Странности субпиксельного chroma. Chroma в OKLCH не ограничен, но полезный диапазон для видимых цветов примерно от 0 до ~0.4. Значения выше 0.4 достижимы только с монохроматическим лазерным светом — это не физические цвета, которые дисплей может отрисовать. Конвертер цветов ограничивает слайдер chroma на 0.4 именно по этой причине; значения сверх него не дают воспринимаемой разницы ни на одном реальном дисплее.
Поддержка браузерами, в стиле 2025 года. Chrome 111+, Safari 15.4+, Firefox 113+ — все нативно поддерживают oklch(). Браузеры до 2023 года — нет. Если вам нужно поддерживать легаси IE/Edge или старую мобильную Safari (1–3% трафика в зависимости от аудитории), вы можете спарить декларацию OKLCH с hex-fallback через @supports (color: oklch(0 0 0)) — но для дизайн-токенов, поставляемых в 2025 году, цена fallback часто перевешивает выгоду от поддержки легаси.
Постоянство hex-кода. OKLCH — для намерения дизайн-системы. Вашей CMS, возможно, по-прежнему нужно hex-значение по легаси-причинам (подписи в email, документы Office, чек-листы брендовых ассетов). Держите сгенерированную таблицу соответствия, которая выдаёт прижатый к sRGB hex для каждого OKLCH-токена, но не пишите в hex.
Не путайте OKLCH и OKLAB. OKLAB — прямоугольная форма (каналы L, a, b); OKLCH — то же цветовое пространство в полярной форме (L, C, H). Между ними конвертируют одним декартово↔полярным шагом. Используйте OKLCH для токенов (читабельнее, проще строить шкалы); используйте OKLAB внутренне, если нужно интерполировать или смешивать цвета.
Попробуйте на собственной палитре
Самый быстрый способ увидеть описанное здесь в действии — бросить брендовый hex в Конвертер цветов. Введите ваш брендовый цвет в поле HEX и считайте вывод OKLCH. Затем подвигайте слайдеры на стороне OKLCH и посмотрите, как тот же оттенок остаётся тем же оттенком при уменьшении chroma и как та же светлота остаётся той же светлотой при повороте оттенка. Через несколько минут у вас сложится интуитивное ощущение того, почему HSL всегда был неверным инструментом для тональных шкал и почему каждая серьёзная дизайн-система от него ушла.
Для конкретной конвертации HEX в OKLCH используйте HEX в OKLCH — та же математика, что и в этой статье, с одним дополнительным бонусом: он показывает классификацию цветового охвата (sRGB / Display P3 / Rec2020), чтобы вы знали, какие из ваших брендовых цветов безопасны везде, а какие требуют устройства с широким охватом, чтобы отрисоваться полностью.
Это и есть OKLCH. Стоит миграции. Сделанное хорошо, вы больше никогда не напишете hsl().