Когда дизайнер просит «осветлить кнопку на 10 %», в hex- или RGB-нотации это превращается в три отдельных подбора: красный канал, зелёный, синий, и проверка, не получился ли в итоге грязно-розовый. Так устроена сама модель: байты R, G, B описывают, сколько каждого луча подмешать в пиксель, но человеческий глаз воспринимает цвет иначе — через яркость, насыщенность и тон. oklch() — это запись цвета именно в этих трёх координатах, поэтому многие задачи UI (тёмная тема, ховер-состояния, согласованные палитры) с ней решаются в одну строку вместо ручного подбора.

Ниже — разбор формата по компонентам, его сравнение с близкими функциями (hsl(), oklab(), lch()), относительный синтаксис, нюансы P3-гаммы и реальный pitfall с градиентами. Про похожую по идее, но менее точную модель уже была отдельная статья про HSL — OKLCH её, по сути, заменяет в большинстве задач.

Что внутри: три параметра и один опциональный

Базовый синтаксис в его absolute-форме выглядит так:

color: oklch(L C H);            /* непрозрачный */
color: oklch(L C H / A);        /* с альфой */

Каждая буква — независимая шкала:

  • L — насколько светлый цвет (в спецификации называется lightness). Число 0…1 или 0%…100%. 0 — чёрный, 1 — белый. Главное отличие от HSL: при одинаковом L два разных цвета будут одинаково яркими на восприятие, а не на бумаге.
  • C — насколько насыщенный цвет (в спецификации — chroma). Диапазон 0…~0.4. 0 — чистый серый (тон в этот момент игнорируется), 0.4 — максимум насыщенности для текущего тона.
  • H — тон (hue), угол на цветовом круге 0…360 (или 0deg…360deg). ~30 — красно-оранжевый, ~140 — зелёный, ~250 — синий.
  • A — прозрачность (alpha), 0…1 или процент. Опциональна, по умолчанию 1.

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

/* светлый, средний и тёмный синий — одинаковые C и H */
.bg-50  { background: oklch(0.95 0.10 250); }
.bg-500 { background: oklch(0.55 0.10 250); }
.bg-900 { background: oklch(0.20 0.10 250); }

/* блёклый, средний и насыщенный — одинаковые L и H */
.muted   { background: oklch(0.55 0.04 30); }
.regular { background: oklch(0.55 0.15 30); }
.vivid   { background: oklch(0.55 0.27 30); }

/* палитра одного «уровня» — меняется только тон */
.tag-red    { background: oklch(0.65 0.18 25);  }
.tag-yellow { background: oklch(0.65 0.18 95);  }
.tag-green  { background: oklch(0.65 0.18 145); }
.tag-blue   { background: oklch(0.65 0.18 250); }

/* альфа — два эквивалентных способа */
.tint-a { background: oklch(0.60 0.20 250 / 0.5);  }
.tint-b { background: oklch(0.60 0.20 250 / 50%); }

Pitfall: насыщенность в процентах считается не как обычно. 100% для C равняется 0.4, а не 1. Спецификация так зафиксировала верхнюю границу, потому что значения > 0.4 на практике почти не используются — они выходят за пределы любых физически существующих экранов. Из-за этого oklch(50% 100% 240) и oklch(50% 0.4 240) — один и тот же цвет.

Чем отличается от HSL, oklab и lch

Внешне формула oklch() похожа на hsl(): три параметра, среди них яркость и тон. Разница не в записи, а в том, что под капотом.

В HSL под L понимается арифметическая середина между минимальным и максимальным каналом — математика красивая, но к глазу отношения не имеет. Поэтому hsl(60 100% 50%) (жёлтый) на восприятие сильно ярче, чем hsl(240 100% 50%) (синий), хотя число у обоих одинаковое. OKLCH построен на цветовом пространстве Oklab, которое подбиралось эмпирически так, чтобы равные L давали действительно одинаково яркие цвета:

/* HSL: одинаковая L=50%, но жёлтый «жжёт», синий «спит» */
.h-yellow { background: hsl(60 100% 50%); }
.h-blue   { background: hsl(240 100% 50%); }

/* OKLCH: одинаковая L=0.6 — оба цвета воспринимаются одинаково светлыми */
.o-yellow { background: oklch(0.6 0.2 95); }
.o-blue   { background: oklch(0.6 0.2 250); }

Практический бонус: на палитре бейджей в первом варианте белый текст на синем будет нормально читаться, а на жёлтом — исчезнет. Во втором палитра ведёт себя ровно: или белый текст подходит ко всем сразу, или ни к одному.

Сравнение с двумя другими модерн-функциями:

  • oklab(L a b) — то же цветовое пространство, но запись не в полярных координатах. a — смещение зелёный/красный, b — синий/жёлтый. Удобно, когда нужно сдвинуть цвет «в сторону» на фиксированную величину, а не повернуть по кругу. Для палитр на сайте полярный вид понятнее.
  • lch(L C H) — почти такой же синтаксис, но построен на старом пространстве CIELAB. У него та же идея перцептивной равномерности, но Oklab лучше справляется с насыщенными синими и фиолетовыми: в CIELAB они «уходят в фиолетовый» при изменении L.

И самый коварный нюанс. У всех трёх функций (hsl, lch, oklch) тон записывается углом, но нулевые градусы у них в разных местах круга:

hsl(0 100% 50%)      /* красный   */
lch(50% 60 0)        /* красный   */
oklch(0.6 0.2 0)     /* розово-малиновый — НЕ красный */
oklch(0.6 0.2 30)    /* вот теперь красный */

Это та ловушка, в которую попадают почти все, кто переписывает палитру из HSL подстановкой имён функций. Если статью читать одной фразой: в OKLCH красный — это примерно 30°, а не 0°.

Как пользуются на практике: палитра из одной яркости

Самая частая задача, ради которой переписывают цвет на OKLCH — согласованная палитра. Берётся одна яркость (L), одна насыщенность (C) и перебирается только тон (H):

:root {
  --tag-l: 0.72;
  --tag-c: 0.14;
}

.tag-css    { background: oklch(var(--tag-l) var(--tag-c) 250); color: #fff; }
.tag-html   { background: oklch(var(--tag-l) var(--tag-c) 30);  color: #fff; }
.tag-js     { background: oklch(var(--tag-l) var(--tag-c) 90);  color: #fff; }
.tag-www    { background: oklch(var(--tag-l) var(--tag-c) 150); color: #fff; }
.tag-jquery { background: oklch(var(--tag-l) var(--tag-c) 200); color: #fff; }

В HSL такой подход даёт пять разноярких заливок: на жёлтом текст почти не виден, на синем — контраст с запасом. В OKLCH у всех пяти заливок физически разная яркость в люменах, но воспринимаются они как одинаковые, и белый текст ведёт себя одинаково везде. Подробнее про подбор контрастного цвета шрифта — в отдельной статье.

Палитра градаций одного тона строится так же, только меняется яркость (L):

:root {
  --brand-h: 250;
  --brand-c: 0.18;
}

.brand-50  { background: oklch(0.96 0.04 var(--brand-h)); }
.brand-100 { background: oklch(0.90 0.06 var(--brand-h)); }
.brand-300 { background: oklch(0.75 0.12 var(--brand-h)); }
.brand-500 { background: oklch(0.55 var(--brand-c) var(--brand-h)); }
.brand-700 { background: oklch(0.40 0.16 var(--brand-h)); }
.brand-900 { background: oklch(0.22 0.10 var(--brand-h)); }

Насыщенность (C) в крайних значениях полезно сбавлять — почти-белые и почти-чёрные с её максимумом выглядят искусственно. Такая раскладка лежит в основе модерн-палитр (Radix, Open Props, Material You).

Тёмная тема в одну линию: оттенки не пересчитываются дизайнером заново, а зеркалятся по L. Подробнее про сам медиа-запрос — в материале про тёмную тему.

:root {
  --surface: oklch(0.97 0.01 250);
  --on-surface: oklch(0.22 0.04 250);
}
@media (prefers-color-scheme: dark) {
  :root {
    --surface:   oklch(0.18 0.02 250); /* 0.97 → 0.18 */
    --on-surface: oklch(0.92 0.04 250); /* 0.22 → 0.92 */
  }
}

Относительный синтаксис: oklch(from …)

Самое полезное расширение OKLCH — относительная форма. Она берёт уже существующий цвет, раскладывает его на l, c, h и позволяет переписать какие-то из компонентов:

oklch(from <color> L C H [ / A])

Внутри функции значения каналов исходного цвета доступны как имена — l, c, h, alpha. Их можно использовать как есть или подставить в calc().

Осветлить любой бренд-цвет на фиксированный шаг:

:root { --brand: #1d4ed8; }

.btn        { background: var(--brand); }
.btn:hover  { background: oklch(from var(--brand) calc(l + 0.08) c h); }
.btn:active { background: oklch(from var(--brand) calc(l - 0.05) c h); }

Самое главное: --brand может быть в любом формате — hex, rgb, hsl, oklch — и логика осветления одинаково работает. Раньше для этого писали SCSS-функции на этапе сборки, теперь — одна строка в рантайме.

Снять насыщенность для disabled-состояния:

.btn:disabled {
  background: oklch(from var(--brand) l calc(c * 0.25) h);
  color: oklch(from var(--brand) calc(l + 0.2) calc(c * 0.25) h);
}

Привести разные цвета к одинаковой яркости — полезно, когда дизайнер дал палитру в hex и просит сделать так, чтобы белый текст читался везде:

:root {
  --c1: #db2777; /* L ≈ 0.58 */
  --c2: #16a34a; /* L ≈ 0.61 */
  --c3: #ca8a04; /* L ≈ 0.67 */
}

.badge-1 { background: oklch(from var(--c1) 0.55 c h); color: #fff; }
.badge-2 { background: oklch(from var(--c2) 0.55 c h); color: #fff; }
.badge-3 { background: oklch(from var(--c3) 0.55 c h); color: #fff; }

Прозрачная вуаль из исходного цвета для оверлеев:

.overlay { background: oklch(from var(--brand) l c h / 0.15); }

Сдвинуть тон на пару градусов для пары акцентных кнопок:

.btn-primary   { background: oklch(from var(--brand) l c h); }
.btn-secondary { background: oklch(from var(--brand) l c calc(h + 25)); }

Сюда же относится трюк с ключевым словом none: оно говорит «этот канал заранее не задан, при смешивании пусть подставится со стороны соседа». На уровне обычной CSS-функции это эквивалент 0, но в градиентах поведение отдельное (см. ниже).

Поддержка браузерами
chrome
Chrome
111
firefox
Firefox
113
edge
Edge
111
safari
Safari
15.4
opera
Opera
97

Цвета шире, чем sRGB

OKLCH адресует не только привычное цветовое пространство sRGB, но и более широкое — Display P3. Это «живые» цвета, которых в sRGB физически нет: насыщенно-красные стоп-сигналы, флуоресцентно-зелёные, глубокие фиолетовые. На Apple-устройствах P3-экраны стоят с 2016 года, на современных Android-флагманах и MacBook — по умолчанию.

/* красный, который sRGB не может — на P3-экране заметно сочнее */
.poster-accent { background: oklch(0.62 0.28 25); }

/* для сравнения: максимально насыщенный sRGB-красный */
.poster-fallback { background: oklch(0.62 0.18 25); }

На sRGB-мониторе разницы между этими двумя строками почти не будет: браузер сам «утолкает» цвет в доступную гамму. На P3-экране первая строка засветится по-настоящему. Если хочется отдать честный фолбек, а на P3 показать что-то более насыщенное — для этого ровно и существует медиа-запрос @media (color-gamut: p3), разобранный в отдельной статье:

.poster {
  background: oklch(0.62 0.18 25);
}
@media (color-gamut: p3) {
  .poster {
    background: oklch(0.62 0.28 25);
  }
}

Градиенты: где спрятан подвох

Главный минус OKLCH — не сам формат, а его поведение в градиентах. Тон (H) — это угол, и между двумя углами всегда есть два пути: короткий и длинный. Между красным (~30deg) и синим (~250deg) короткий идёт через фиолетовый, длинный — через жёлтый и зелёный. Браузер должен выбрать.

В обычном linear-gradient(red, blue) цвет интерполируется в sRGB — и градиент проходит через серо-фиолетовую кашу посередине. С указанием цветового пространства результат становится перцептивно ровным, но появляется вопрос про путь по кругу:

/* старая школа: серое болото в центре */
.g-srgb {
  background: linear-gradient(to right, oklch(0.6 0.2 30), oklch(0.6 0.2 250));
}

/* по короткому пути в OKLCH — через пурпурный */
.g-shorter {
  background: linear-gradient(in oklch shorter hue, oklch(0.6 0.2 30), oklch(0.6 0.2 250));
}

/* по длинному пути — через жёлтый и зелёный, как у радуги */
.g-longer {
  background: linear-gradient(in oklch longer hue, oklch(0.6 0.2 30), oklch(0.6 0.2 250));
}

По умолчанию выбирается shorter hue, и в большинстве случаев это правильно. Но если градиент идёт между близкими цветами, расположенными по разные стороны от нуля (например, тёмно-красный 355deg и красно-оранжевый 15deg), shorter hue пойдёт через нужный кратчайший путь в 20° — а вот longer hue в той же паре сделает 340° по кругу через зелёное и синее. Поэтому переключатель важен.

На многоступенчатых градиентах помогает фиксировать L и C равными и менять только H — это даёт идеальную «радугу» одной яркости:

.rainbow {
  background: linear-gradient(
    in oklch longer hue,
    oklch(0.7 0.2 0),
    oklch(0.7 0.2 360)
  );
}

Фолбек для старых браузеров

Формально oklch() поддерживают все актуальные движки с 2023 года, относительный синтаксис — чуть позже. На старых движках цвет просто проигнорируется. Cтандартный паттерн — писать две строки подряд, первая работает везде, вторая перекрывает:

.btn {
  background: #1d4ed8;                 /* sRGB-фолбек */
  background: oklch(0.55 0.18 250);    /* современный */
}

Если хочется надёжнее, есть детектор фичи через @supports:

.btn { background: #1d4ed8; }

@supports (color: oklch(0 0 0)) {
  .btn { background: oklch(0.55 0.18 250); }
}

Для относительного синтаксиса детектор пишется отдельно — он добавился позже, и есть сборки браузеров с поддержкой oklch(), но без from:

@supports (color: oklch(from red l c h)) {
  /* можно использовать относительный oklch */
}

Итог

RGB и hex останутся базовыми по понятным причинам — их понимает любая среда, от Figma до старой почтовой клиента. Но в местах, где надо вычислять цвета — светлеть, темнеть, унифицировать яркость, строить градиенты — oklch() убирает из работы всю невнятную «подгонку каналов». Достаточно один раз сложить мысленный словарь: L — это насколько светло, C — это насколько насыщенно, H — это в какую сторону спектра, — и большинство задач палитры начинают решаться арифметикой одного канала. P3-цвета и относительный синтаксис — бонус сверху, ради которого многие команды переходят на OKLCH вообще без оглядки на старые движки.