Mix-blend-mode: как CSS смешивает слои и как этим чинить контраст текста на фото

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

Есть другой путь — не притемнять фон, а заставить сам текст реагировать на то, что под ним. В CSS для этого есть mix-blend-mode, и это тот случай, когда одно свойство закрывает задачу, под которую обычно пишут скрипт с анализом яркости картинки.

Что вообще происходит, когда браузер смешивает два слоя

Смешивание — это поканальная функция от двух чисел: цвета подложки (в спецификации он называется Cb, backdrop) и цвета накладываемого слоя (Cs, source). Оба нормализованы к диапазону от 0 до 1, функция считается отдельно для красного, зелёного и синего. Двенадцать режимов из шестнадцати устроены именно так — их называют разделяемыми (separable), потому что каналы считаются независимо. normal тривиален (берётся верхний цвет), у soft-light формула кусочная и в строку таблицы не помещается, остальные десять выглядят так:

Режим Формула из спецификации
multiply B = Cb × Cs
screen B = 1 − (1 − Cb) × (1 − Cs)
darken B = min(Cb, Cs)
lighten B = max(Cb, Cs)
color-dodge B = min(1, Cb / (1 − Cs))
color-burn B = 1 − min(1, (1 − Cb) / Cs)
hard-light multiply или screen в зависимости от того, темнее ли Cs половины
overlay то же, что hard-light, но слои поменяны местами
difference B = abs(Cb − Cs)
exclusion B = Cb + Cs − 2 × Cb × Cs

Оставшиеся четыре — hue, saturation, color, luminosity — неразделяемые: они разбирают цвет на тон, насыщенность и светлоту и берут разные составляющие из разных слоёв. color, например, берёт тон и насыщенность сверху, а светлоту снизу — именно поэтому им красят чёрно-белые фотографии.

Проще один раз увидеть все шестнадцать на одной и той же картинке. Ниже белое слово #fff лежит поверх одного и того же файла, меняется только mix-blend-mode:


Рендер в Chromium 141, разметка и цвет текста во всех плитках одинаковые

Info:

В плитках multiply, darken и color-burn текст исчез не по ошибке: для всех трёх белый — нейтральный элемент, формула сводится к B = Cb, то есть к подложке без изменений. У hue, saturation и color причина другая: белый источник не несёт ни тона, ни насыщенности, поэтому на выходе остаётся подложка, у которой отобрали цвет, — на светлых участках разница почти неразличима. Если режим «не работает», первым делом смотрите не на браузер, а на то, не подали ли вы на вход нейтральный цвет.

Почему из шестнадцати для контраста берут два

Идею, с которой обычно начинают знакомство с темой, коротко сформулировал Cody Lindsay Gordon: вместо подложек поставить логотипу mix-blend-mode: exclusion, и он сам подстроится под яркость фотографии.

.blend-me {
  mix-blend-mode: exclusion;
}

Механика видна прямо из формул. У difference при белом источнике B = |Cb − 1| = 1 − Cb: тёмный фон даёт светлый текст, светлый фон — тёмный. Инверсия происходит сама, без единой строчки JavaScript и без анализа гистограммы. У exclusion результат тот же по знаку, но мягче в полутонах — картинка под текстом остаётся видна лучше.

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

<h1 class="blend-me">Hello world</h1>
<figure>
  <img src="/hero.jpg" alt="">
</figure>

И где эти два режима ломаются

Теперь неприятное. Подставьте в формулу difference фон ровно посередине шкалы: |0.5 − 1| = 0.5. Результат совпал с подложкой — текст растворился. У exclusion то же самое: 0.5 + 1 − 2 × 0.5 × 1 = 0.5. Оба режима дают ноль контраста ровно там, где фотография средне-серая, а это самая частая яркость у реальных снимков.


Одна и та же надпись поверх шкалы яркости от 0 % до 100 %, рендер в Chromium 141

Error:

Ставить mix-blend-mode: difference на основной текст интерфейса — и считать вопрос контраста закрытым. WCAG требует 4.5:1 для обычного текста и 3:1 для крупного (от 24 px, или от 18.66 px полужирным). Результат смешивания зависит от картинки, которую загрузит редактор, поэтому гарантировать эти цифры заранее нельзя в принципе. Проверять «на глаз на одной фотографии» — не проверка.

Отсюда практическое правило: смешивание отлично работает для крупной декоративной типографики, монограмм, цифр, разделителей и наведения — то есть там, где потеря читаемости не ломает сценарий. Для подписей, кнопок и абзацев нужна предсказуемая подложка.

Матрица выбора: какой режим под какую задачу

Задача Режим Что учесть
Крупный логотип или заголовок поверх произвольного фото difference, exclusion провал контраста на средне-серых участках
Затемнить картинку под белый текст multiply с тёмным цветом предсказуемо, но гасит саму фотографию
Осветлить картинку под тёмный текст screen со светлым цветом съедает детали в тенях
Дуотон, единый стиль всех обложек color или luminosity считается один раз, можно закешировать в CSS
Наложить текстуру шума или бумаги multiply, soft-light держите текстуру мелкой, её вес идёт в трафик
Свечение, вспышки, частицы screen, plus-lighter plus-lighter есть в Chromium 141, plus-darker — нет
Перекрасить чёрно-белую иконку color на цветном источнике результат непредсказуем
Подсветить область под курсором difference на элементе-курсоре свойство не анимируется, анимируйте позицию
Important:

mix-blend-mode не анимируется — в MDN так и написано: Animation type: Not animatable. Переключать его в @keyframes бессмысленно; анимируют то, что лежит под ним и над ним, — положение, размер, цвет.

Ловушка первая: элемент смешивается не со всей страницей

Самая частая жалоба звучит как «у меня то же самое, но не работает». Почти всегда причина одна: смешивание идёт не со всем, что нарисовано ниже, а только с содержимым ближайшего контекста наложения (stacking context). Стоит между текстом и картинкой появиться обёртке, которая создаёт свой контекст, — и смешиваться становится не с чем.

А контекст наложения создаёт куда больше свойств, чем помнят наизусть: transform, filter, opacity меньше единицы, will-change, position: fixed, contain: paint, элемент внутри flex- или grid-контейнера с z-index, отличным от auto. Сам mix-blend-mode тоже создаёт контекст — на том элементе, где он объявлен.


Разметка во всех трёх карточках одинаковая, отличается одно свойство на обёртке. Рендер в Chromium 141

Warning:

Есть и обратная беда. Если контекст не создать вообще, элемент будет смешиваться со всем, что оказалось под ним ниже по дереву, — вплоть до фона страницы и соседних блоков. Лечится это одной строкой на общем родителе:

.hero {
  isolation: isolate;
}

isolation: isolate принудительно создаёт контекст наложения и ограничивает смешивание содержимым этого блока. Свойство поддерживается всеми браузерами с января 2020 года.

Отдельная неочевидная история — фон на body. Фон корневого элемента и body браузер продвигает на канву окна, и в смешивании он не участвует: элемент с mix-blend-mode будет смешиваться с прозрачностью, то есть визуально ни с чем. Обложка к этой статье собрана именно так, как надо: фотография лежит на внутреннем блоке-сцене, у которого стоит isolation: isolate, а заголовок над ней идёт с difference.

Ловушка вторая: background-blend-mode решает другую задачу

Похожее название, другая область действия. mix-blend-mode смешивает элемент с тем, что нарисовано под ним. background-blend-mode смешивает между собой слои фона одного и того же элемента — несколько background-image и background-color под ними. Никаких дополнительных обёрток и псевдоэлементов для затемнения не нужно.


Во всех четырёх карточках один и тот же файл в background-image, рендер в Chromium 141

Дуотон целиком помещается в четыре строки:

.duotone {
  background-image: linear-gradient(135deg, #ff2d6f, #00d4ff), url("/hero.jpg");
  background-blend-mode: color;
  background-size: cover;
  background-position: center;
}

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

Success:

Затемнение под белый текст без единого лишнего узла в разметке:

.hero {
  background-image: url("/hero.jpg");
  background-color: #1b2440;
  background-blend-mode: multiply;
  background-size: cover;
}

Дальше меняете только background-color — и получаете любую степень притемнения без второго div, без ::after и без правок HTML.

Что положить в продакшн

Рабочая схема выглядит так: предсказуемая подложка как база, смешивание — как улучшение сверху, и проверка поддержки через @supports.

.hero {
  position: relative;
  isolation: isolate;
  background: url("/hero.jpg") center / cover;
}

/* база: работает всегда */
.hero__title {
  color: #fff;
  text-shadow: 0 1px 3px rgb(0 0 0 / 0.75);
}

.hero::before {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(to top, rgb(0 0 0 / 0.55), transparent 60%);
}

/* улучшение: только для крупного декоративного заголовка */
@supports (mix-blend-mode: difference) {
  .hero__logo {
    mix-blend-mode: difference;
    color: #fff;
  }
}

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

Источники

Смежное по теме на форуме: CSS в письме читает пароли: разбор исследования PortSwigger и Пила на нулевой громкости: скрытый AudioContext на AliExpress.

Question:

Пускали mix-blend-mode в боевой проект — или обошлись градиентной подложкой? И был ли случай, когда режим вёл себя по-разному в Chromium и в Safari: интересно собрать список расхождений, которые ловили руками.