Главная » Css live » Укрощаем режимы наложения: difference и exclusion

Укрощаем режимы наложения: difference и exclusion

Перевод статьи Taming Blend Modes: `difference` and `exclusion` с сайта css-tricks.com, переведено для css-live.ru с разрешения автора — Аны Тюдор.

До самого 2020-го я не особо увлекалась режимами наложения, во многом потому, что крайне плохо представляла себе будущий результат до того, как попробовать. И этот подход «попробуй и посмотри, что выйдет» почти всегда оставлял меня в ужасе перед тем безобразием, что невольно получалось у меня на экране.

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

Момент просветления случился у меня, когда я наткнулась на спецификацию и нашла в ней математические формулы, по которым работают режимы наложения. Благодаря этому я наконец смогла понять, как это всё работает «под капотом» и где оно может пригодиться. И теперь, узнав это лучше, я поделюсь этим знанием в серии статей.

Сегодня мы рассмотрим, как вообще работает наложение, затем рассмотрим два в чем-то похожих режима наложения — difference и exclusion — и, наконец, доберемся до главной части этой статьи, где разберем несколько классных примеров использования вроде вот таких.

Несколько примеров, чего можно добиться с этими двумя режимами наложения.

Поговорим о том, как устроены режимы наложения

Наложение означает объединение двух слоев (один поверх другого) и получение из них одного слоя. Эти два слоя могут быть соседними элементами, в этом случае мы используем CSS-свойство mix-blend-mode. Это могут быть и два слоя фона (background), в таком случае нам нужно CSS-свойство  background-blend-mode. Обратите внимание, что к наложению «соседних элементов» относятся также наложение псевдоэлементов на элемент или текстового содержимого на background его родителя. А говоря о слоях background, я подразумеваю не только слои background-imagebackground-color тоже вполне себе слой.

При наложении двух слоёв верхний слой называется источником (source), а нижний — целью (destination). Это я принимаю как данность, потому что смысла в этих названиях немного, по крайней мере для меня. Я бы ожидала, что цель — это то, что на выходе, но на деле оба эти слоя — входные данные, а на выходе получается результирующий слой.

Иллюстрация с двумя слоями. Верхний слой — источник, а нижний слой — цель.
Терминология наложения

То, как именно мы комбинируем два слоя, зависит от выбранного режима наложения, но это всегда происходит попиксельно. Например, на иллюстрации ниже два слоя, представленные как мозаики из пикселей, объединяются с помощью режима наложения multiply.

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

Отлично, но что будет, если у нас больше двух слоев? Что ж, в этом случае процесс наложения происходит поэтапно, начиная с самого низа.

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

Иллюстрация процесса, описанного выше.
Наложение нескольких слоев

Естественно, мы можем на каждом этапе использовать свой режим наложения. Например, можно применить difference для наложения двух самых нижних слоев, а затем multiply для наложения третьего слоя на полученный результат. Но эту тему мы копнем чуть глубже в будущих статьях.

Результат, который дают два режима наложения из сегодняшней статьи, не зависит от того, какой из слоёв окажется сверху. Учтите, что для некоторых других режимов наложения это не так.

Эти два режима, difference и exclusion, также относятся к «разделяемым» (separable) режимам наложения, что означает, что операция наложения производится над каждым каналом отдельно. Опять же, у других режимов наложения бывает иначе.

Если точнее, красный канал результата зависит только от красного канала источника и красного канала цели; зеленый канал результата зависит только от зеленого канала источника и зеленого канала цели, и, наконец, синий канал результата зависит только от синего канала источника и синего канала цели.

R = fB(Rs, Rd)
G = fB(Gs, Gd)
B = fB(Bs, Bd)

Для произвольного канала, без уточнения, красный он, зеленый или синий, у нас получается функция двух соответствующих каналов источника (верхнего слоя) и цели (нижнего слоя):

Ch = fB(Chs, Chd)

Важно держать в уме, что RGB-значения можно представить либо в виде интервалов  [0, 255], либо в виде процентных интервалов [0%, 100%], а в формулах мы используем проценты в виде десятичных дробей. Например, багровый цвет (crimson) можно записать как rgb(220, 20, 60) либо как rgb(86.3%, 7.8%, 23.5%) — и так, и так правильно. Значения каналов, которые мы берем для расчетов для пикселя цвета crimson — проценты, выраженные десятичной дробью, то есть .863, .078, .235.

Если пиксель черный (black), то все значения каналов для расчетов равны 0, поскольку black можно записать как rgb(0, 0, 0) или rgb(0%, 0%, 0%). Если он белый (white), то все значения каналов равны 1, поскольку white записывается как rgb(255, 255, 255) или rgb(100%, 100%, 100%).

Заметьте, что во всех случаях полной прозрачности (альфа-канал равен 0) результат будет идентичен другому слою.

difference

Название этого режима (переводится «разница» или «разность») может подсказать, что делает функция наложения fB(). Результат — это абсолютное значение разности между значениями соответствующих каналов для двух слоёв.

Ch = fB(Chs, Chd) = |Chs - Chd|

Прежде всего, это значит, что если у соответствующих пикселей в двух слоях идентичные RGB-значения (т.е. Chs= Chd для всех трех каналов), то итоговый пиксель будет black, поскольку разности для всех трех каналов равны 0.

Chs = Chd
Ch = fB(Chs, Chd) = |Chs - Chd| = 0

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

Если пиксель со значением black у нас в верхнем слое (источнике), замена значений его каналов в нашей формуле на 0 дает нам:

Ch = fB(0, Chd) = |0 - Chd| = |-Chd| = Chd

Если же пиксель со значением black в нижнем слое (цели), то замена значений его каналов в нашей формуле на 0 дает:

Ch = fB(Chs, 0) = |Chs - 0| = |Chs| = Chs

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

Если пиксель со значением white в верхнем слое (источнике), замена значений его каналов в нашей формуле на 1 дает нам:

Ch = fB(1, Chd) = |1 - Chd| = 1 - Chd

Если же пиксель со значением white в нижнем слое (цели), то замена значений его каналов в нашей формуле на 1 дает:

Ch = fB(Chs, 1) = |Chs - 1| = 1 - Chs

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

See the Pen
перевод примера Аны Тюдор https://codepen.io/thebabydino/pen/yLJOPWq к статье о режимах наложения
by Ilya Streltsyn (@SelenIT)
on CodePen.

exclusion

Для второго режима наложения, которым мы завершим сегодняшний экскурс, результат — сумма значений каналов за вычетом их удвоенного произведения:

Ch = fB(Chs, Chd) = Chs + Chd - 2·Chs·Chd

Поскольку оба значения лежат в интервале [0, 1], их произведение всегда меньше либо равно меньшему из них, так что удвоенное произведение не превышает их суммы.

Если взять пиксель со значением black в верхнем слое (источнике), то, заменив в этой формуле Chs на 0, мы получим такой результат для каналов соответствующего пикселя результата:

Ch = fB(0, Chd) = 0 + Chd - 2·0·Chd = Chd - 0 = Chd

Если взять пиксель со значением black в нижнем слое (цели), то, заменив в этой формуле Chd на 0, мы получим такой результат для каналов соответствующего пикселя результата:

Ch = fB(Chs, 0) = Chs + 0 - 2·Chs·0 = Chs - 0 = Chs

Итак, если у пикселя в одном слое значение black, то соответствующий пиксель результата идентичен пикселю другого слоя.

Если взять пиксель со значением white в верхнем слое (источнике), то, заменив в этой формуле Chs на 1, мы получим такой результат для каналов соответствующего пикселя результата:

Ch = fB(1, Chd) = 1 + Chd - 2·1·Chd = 1 + Chd - 2·Chd = 1 - Chd

Если взять пиксель со значением white в нижнем слое (цели), то, заменив в этой формуле Chd на 1, мы получим такой результат для каналов соответствующего пикселя результата:

Ch = fB(Chs, 1) = Chs + 1 - 2·Chs·1 = Chs + 1 - 2·Chs = 1 - Chs

Таким образом, если у пикселя в одном слое значение white, то соответствующий пиксель результата идентичен инвертированному пикселю другого слоя.

Всё это показано в следующем интерактивном примере:

See the Pen
exclusion blend mode in action
by Ilya Streltsyn (@SelenIT)
on CodePen.

Заметьте, что если хотя бы один из слоев содержит только черные (black) и белые (white) пиксели, то difference и exclusion дают в точности один и тот же результат.

А теперь давайте посмотрим, на что способны режимы наложения

Сейчас будет интересная часть — примеры!

Эффект изменения состояния текста

Допустим, у нас есть абзац со ссылкой:

<p>Hello, <a href='#'>World</a>!</div>

Первым делом зададим базовые стили, чтобы текст был по центру экрана, увеличим ему font-size, укажем background для body и color для абзаца и ссылки.

body { display: grid; place-content: center; height: 100vh; background: #222; color: #ddd; font-size: clamp(1.25em, 15vw, 7em);
} a { color: gold; }

Пока выглядит простовато, но сейчас мы это изменим!

Скриншот результата с начальными стилями. Текст абзаца выровнен по центру. Обычный текст белый, а текст ссылки золотистый.
Что у нас пока получилось (пример)

Следующий шаг — создать абсолютно позиционированный псевдоэлемент, накрывающий всю ссылку, и задать ему background со значением currentColor.

a { position: relative; color: gold; &::after { position: absolute; top: 0; bottom: 0; right: 0; left: 0; background: currentColor; content: ''; }
}
Скриншот результата после создания псевлоэлемента для ссылки и задания ему базовых стилей: сейчас он накрывает весь текст ссылки.
Результат с псевдоэлементом на ссылке (пример)

Пример выше выглядит, будто мы всё поломали… но поломали ли? Здесь у нас золотистый (gold) прямоугольник поверх золотистого же текста. И если вы обратили внимание на то, как работают рассмотренные ранее режимы наложения, то наверняка уже догадались, что будет дальше: мы наложим друг на друга два соседних элемента внутри ссылки (псевдоэлемент-прямоугольник и текстовое содержимое) с помощью difference, и поскольку оба они цвета gold, в результате их общая часть — текст — станет черной (black).

p { isolation: isolate;
} a { /* все прежние стили */ &::after { /* все прежние стили */ mix-blend-mode: difference; }
}

Обратите внимание, что нам пришлось изолировать (isolate) абзац, чтобы он не накладывался на background элемента body. Хотя это происходит только в Firefox (и благодаря очень темному фону body не так уж заметно), а в Chrome все и так нормально, помните, что по спецификации как раз Firefox ведет себя правильно. Баг здесь именно в поведении Chrome, так что надо задать isolation на будущее, когда его пофиксят.

Скриншот результата после наложения псевдоэлемента ссылки на ее текст. Поскольку они оба золотистого цвета, результат — черный текст на золотистом фоне.
Эффект mix-blend-mode: difference (пример)

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

a { /* все прежние стили */ text-decoration: none; &::after { /* все прежние стили */ transform: scale(0); } &:focus { outline: none } &:focus, &:hover { &::after { transform: none; } }
}

Заодно мы убрали у ссылки подчеркивание и рамку фокуса. Ниже вы можете увидеть эффект разности при :hover (тот же эффект происходит и при :focus, что можно проверить в живом примере).

Анимированный гиф. При наведении внезапно появляется золотистый прямоугольник и накладывается на текст ссылки в режиме difference, делая его черным.
Эффект mix-blend-mode: difference только при :hover (пример)

Теперь состояние меняется, но слишком резко, так что добавим-ка transition!

a { /* все прежние стили */ &::after { /* все прежние стили */ transition: transform .25s; }
}

Намного лучше!

Анимированный гиф. При наведении плавно вырастает из ниоткуда золотистый прямоугольник и накладывается на текст ссылки в режиме difference, делая его черным.
Эффект mix-blend-mode: difference только при :hover, теперь с плавным переходом благодаря transition (пример)

Было бы еще лучше, если бы псевдоэлемент вырастал не из точки в центре, а из тонкой линии внизу. Это значит, что нам надо задать transform-origin по нижнему краю (на 100% по вертикали и где угодно по горизонтали) и изначально уменьшить псевдоэлемент чуть-чуть меньше чем до нуля по оси y.

a { /* все прежние стили */ &::after { /* все прежние стили */ transform-origin: 0 100%; transform: scaleY(.05); }
}
Анимированный гиф. При наведении золотистый прямоугольник плавно вырастает из тонкого подчеркивания в прямоугольник, накладывающийся на текст ссылки в режиме difference, делая область их пересечения черным.
Эффект mix-blend-mode: difference только при :hover, теперь с плавным переходом между тонким подчеркиванием и прямоугольником с текстом благодаря transition (пример)

Еще я бы поменяла font абзаца на более эстетически притягательный, так что давайте позаботимся и об этом! Но теперь у нас другая проблема: при :focus/:hover «хвостик» буквы «d» торчит из прямоугольника наружу.

 Скриншот, показывающий проблему с наклонным текстом — последняя буква заканчивается снаружи прямоугольника псевдоэлемента.
Иллюстрация проблемы: при :focus/:hover на ссылке «хвостик» буквы «d» торчит из прямоугольника наружу (пример)

Это можно исправить горизонтальным padding-ом для нашей ссылки.

a { /* все прежние стили */ padding: 0 .25em;
}

Если вас удивило, почему мы задаем этот padding и справа и слева, а не только padding-right, вот иллюстрация причины. Если текст ссылки изменится на «Alien World», без padding-left выгнутое начало от «A» в итоге окажется снаружи прямоугольника.

Скриншот, показывающий проблему с боковым паддингом только со стороны наклонной буквы (в данном случае справа): если текст меняется на «Alien World», загнутое начало от «A» выпадает наружу прямоугольника псевдоэлемента. Это решается боковым паддингом с обеих сторон.
Зачем нам боковой отступ с обеих сторон (пример)

Этот же пример с многословной ссылкой показывает и еще одну проблему при уменьшении ширины окна.

Анимированный гиф. Показывает, что в случае многострочной ссылки псевдоэлемент растягивается только от левого края первого слова в ссылке до правого края последнего слова текста этой же ссылки на новой строке.
Проблема с многострочными ссылками (пример)

Быстрым фиксом тут может быть display: inline-block для ссылки. Это не идеальное решение. Оно тоже ломается, когда длина текста ссылки больше ширины окна, но в нашем случае оно работает, так что пока оставим так и вернемся к этой проблеме чуть позже.

Анимированный гиф. Показывает, как в данном конкретном случае работает фикс с inline-block.
Решение с inline-block (пример)

Теперь давайте посмотрим, что у нас в светлой теме. Поскольку наложением двух отличных от белого цветов нельзя получить белый (white) текст ссылки вместо черного (black) при :hover или :focus, нам понадобится чуть другой подход, включающий не только режимы наложения

Для этого случая мы делаем так: cначала задаем для background, color для простого текста в абзаце и color текста ссылки нужные нам значения, но инвертированные. Раньше я инвертировала их вручную, но потом мне дали отличную подсказку насчет Sass-функции invert(), которая здорово упростила дело. Затем, когда у нас получится тёмная тема, по сути представляющая собой инвертированный вариант («негатив») искомой светлой, для желаемого результата нам надо будет инвертировать все еще раз с помощью CSS-фильтра invert().

Здесь есть небольшой «подводный камень»: нельзя задать filter: invert(1) для элементов body или html, потому что это работает не так, как ожидалось, и нужного эффекта не даст. Но можно задать и background, и filter обертке вокруг нашего абзаца.

<section> <p>Hello, <a href='#'>Alien World</a>!</p>
</section>
body { /* все прежние стили, но без объявлений place-content, background и color, которые мы переносим на section */
} section { display: grid; place-content: center; background: invert(#ddd) /* Sass-функция invert(<color>) */; color: invert(#222); /* Sass-функция invert<color>) */; filter: invert(1); /* CSS-фильтр invert(<number|percentage>) */
} a { /* все прежние стили */ color: invert(purple); /* Sass-функция invert(<color>) */
}

See the Pen
Text state change effect, step 10: light theme
by Ana Tudor (@thebabydino)
on CodePen.

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

See the Pen
Navigation effect
by Ana Tudor (@thebabydino)
on CodePen.

Еще один момент, с которым надо быть очень внимательными, вот какой: этот прием инвертирует всех потомков нашего элемента section. Пожалуй, это не совсем то, что нам нужно в случае элементов img — лично я точно не хочу, чтобы картинки в блоге инвертировались, когда я переключаюсь с темной темы на светлую. Следовательно, каждый img в нашем section нужно еще раз инвертировать через filter, чтобы опять получилось исходное состояние.

section { /* все прежние стили */ &, & img { filter: invert(1); }
}

Собирая всё это воедино, ниже показан пример обеих тем (темной и светлой) с картинками:

See the Pen
Link hover effect (no text duplication)
by Ana Tudor (@thebabydino)
on CodePen.

А теперь вернемся к проблеме переноса текста в ссылке и посмотрим, нет ли у нас вариантов лучше, чем делать ссылки inline-block‘ами.

Оказывается, есть! Вместо текстового содержимого и псевдоэлемента можно наложить друг на друга два слоя background. Один слой обрезается по тексту, а другой — по границе border-box, и его вертикальный размер анимируется между 5% в обычном состоянии и 100% при наведении и фокусе.

a { /* все прежние стили */ -webkit-text-fill-color: transparent; -moz-text-fill-color: transparent; --full: linear-gradient(currentColor, currentColor); background: var(--full), var(--full) 0 100%/1% var(--sy, 5%) repeat-x; -webkit-background-clip: text, border-box; background-clip: text, border-box; background-blend-mode: difference; transition: background-size .25s; &:focus, &:hover { --sy: 100%; }
}

Обратите внимание, что мы теперь вообще не используем псевдоэлемент, так что часть его CSS мы перенесли на саму ссылку и немного «доработали напильником» под этот новый способ. Мы перешли с mix-blend-mode на background-blend-mode; у нас теперь анимируется background-size вместо transform и в состояниях :focus and :hover у нас теперь меняется не transform, а кастомное свойство, отвечающее за вертикальную составляющую background-size.

Анимированный гиф. Показывает, что получается при наложении двух слоев фона одной ссылки: один обрезан по тексту, а второй — по краю border-box.
Решение с наложением фоновых слоев (пример).

Уже намного лучше, но тоже пока не идеально.

Первую проблему вы наверняка заметили, если открыли ссылку на пример из подписи к картинке в Firefox: оно просто не работает. Это из-за бага в Firefox, который я, судя по всему, сама завела еще в 2018-м, но напрочь о нем забыла, пока не стала играть с режимами наложения и опять на него наткнулась.

Вторую проблему видно на записи с экрана. Ссылки выглядят какими-то блеклыми. Это из-за того, что Chrome почему-то накладывает строчные элементы вроде ссылок (заметьте, что с блочными элементами вроде дивов этого не происходит) на background их ближайшего предка (в данном случае section), если у этих строчных элементов любое значение background-blend-mode, кроме normal.

Что еще более странно, задание isolation: isolate для ссылки или ее родительского абзаца от этого не спасает. Меня до сих пор гложет чувство, что это как-то связано с контекстами наложения, так что я решила попробовать перебрать все возможные хаки, авось что-нибудь да сработает. Что ж, долго возиться не пришлось. Значение opacity меньше единицы (но достаточно близко к 1, чтобы элемент по-прежнему выглядел полностью непрозрачным) решает проблему.

a { /* все прежние стили */ opacity: .999; /* хак для борьбы с «блеклостью» ¯_(ツ)_/¯ */
}
Анимированный гиф. Показывает результат после применения хака с opacity.
Результат, когда проблема с наложением решена (пример)

Последнюю проблему тоже можно заметить на записи. Если присмотреться к последней букве в слове «Amur», можно увидеть, что ее правый край обрезан, поскольку выступает за край фонового прямоугольника. Это особенно заметно в сравнении с буквой «r» в слове «leopard».

Я не особо надеялась ее решить, но всё равно задала вопрос в Твиттере. И что бы вы думали, решение нашлось! С помощью box-decoration-break в сочетании с padding, который мы уже задали, нужный эффект достигается!

a { /* все прежние стили */ box-decoration-break: clone;
}

Обратите внимание, что для box-decoration-break всё еще нужен префикс -webkit- во всех вебкитных браузерах, но в отличие от свойств типа background-clip в случаях, когда хотя бы одно из значений равно text (т.е. стандартное свойство с нестандартным, но повсеместно поддерживаемым значением — прим. перев.), с этим прекрасно справляются автоматические средства расстановки префиксов. Поэтому я не пишу в этом коде версию с префиксом.

Анимированный гиф. Показывает результат после применения решения с box-decoration-break.
Результат после исправления проблемы с обрезкой (пример).

Еще мне подсказали добавить отрицательный margin, чтоб компенсировать padding. Я пробовала и так, и сяк — не могу решить, как лучше, с ним или без. В любом случае, упомянуть такой вариант стоит.

$p: .25em; a { /* все прежние стили */ margin: 0 (-$p); /* пишем в скобках, чтобы Sass не принял эту запись за вычитание */ padding: 0 $p;
}
Анимированный гиф. Показывает результат после добавления margin для компенсации padding.
Результат с отрицательным margin, компенсирующим padding (пример)

И всё же признаюсь, что анимация одних лишь background-position или background-size для градиента выглядит скучновато. Но благодаря Houdini теперь можно не ограничивать полет фантазии и анимировать какой угодно компонент градиента, пусть даже пока это работает только в Chromium. Например, радиус для radial-gradient() как в примере ниже или «процент заполнения» для conic-gradient().

Animated gif. Shows a random bubble growing from nothing and being blended with the text of a navigation link every time this is being hovered or focused.
Навигация с эффектом «надувающихся пузырей» (пример)

Инвертировать только часть элемента (или фона)

Я часто вижу реализацию подобного эффекта с помощью дублирования элемента. Иногда копии элемента наложены одна поверх другой, и для верхней применяются filter и clip-path, чтобы были видны оба слоя. Другой путь — наложение второго элемента с таким значением прозрачности, чтобы его практически не было видно, и backdrop-filter.

Оба этих подхода решают задачу, если нужно инвертировать часть всего элемента со всем его содержимым и потомками, но бессильны, если надо инвертировать лишь часть фона — и filter, и backdrop-filter влияют на целый элемент, а не только его background. И хотя новая CSS-функция filter() (уже работающая в Safari) умеет влиять только на слои background, она действует на всю площадь фона, а не на ее часть.

Здесь и вступает в игру наложение. Всё довольно очевидно: берем слой background, часть которого мы хотим инвертировать, и добавляем один или несколько слоев градиента, создающие белые области там, где нам нужна инверсия основного слоя, и прозрачные (либо черные) в остальных местах. Затем используем наложение одним из двух вышеописанных режимов. Для эффекта инверсии я предпочитаю exclusion (он на целый символ короче, чем difference).

Вот первый пример. У нас есть квадратный элемент с двухслойным background’ом. Эти два слоя — картинка с котиком и градиент с резким переходом между белым и прозрачным.

div { background: linear-gradient(45deg, white 50%, transparent 0), url(cat.jpg) 50%/ cover;
}

Получается следующий результат. По ходу дела мы добавили еще размеры, border-radius, тени и более красивый текст, но всё это несущественно для нашей основной задачи.

Скриншот. Показывает квадрат, где левая нижняя половина фото котика (под главной диагональю) накрыта сплошным белым фоном.
Два слоя фона, один поверх другого

Далее нам нужно всего одно CSS-объявление, чтобы инвертировать левую нижнюю половину:

div { /* все прежние стили */ background-blend-mode: exclusion; /* либо difference, но это на 1 знак длиннее */
}

Обратите внимание, что текст инверсией не затронут, она применяется только к background.

Скриншот. Показывает квадрат c котиком на фоне, где левая нижняя половина (под главной диагональю) инвертирована (показывает негатив картинки).
Конечный результат (пример)

Вам наверняка знакомы интерактивные слайдеры картинок «было — стало». Возможно, вы даже видели такое на самом CSS-Tricks. Я видела это на Compressor.io, которым я часто оптимизирую картинки, в т.ч. для этих статей!

Наша задача — сделать что-то подобное, используя всего один CSS-элемент, менее 100 байт JavaScript — и не так уж много CSS!

Нашим элементом будет input с типом range («ползунок»). Мы не будем задавать ему атрибутов min и max, так что они получат значения по умолчанию — 0 и 100, соответственно. Атрибут value мы тоже не задаем, по умолчанию он будет 50, и это же значение мы зададим кастомному свойству --k, указанному в атрибуте style.

<input type='range' style='--k: 50'/>

В CSS мы начнем с базового сброса, затем превратим наш input в блочный элемент, занимающий всю высоту окна. Мы также укажем размеры и временный фон для его «дорожки» и «ручки», просто чтобы сразу увидеть хоть что-то на экране.

$thumb-w: 5em; @mixin track() { border: none; width: 100%; height: 100%; background: url(flowers.jpg) 50%/ cover;
} @mixin thumb() { border: none; width: $thumb-w; height: 100%; background: purple;
} * { margin: 0; padding: 0;
} [type='range'] { &, &::-webkit-slider-thumb, &::-webkit-slider-runnable-track { -webkit-appearance: none; } display: block; width: 100vw; height: 100vh; &::-webkit-slider-runnable-track { @include track; } &::-moz-range-track { @include track; } &::-webkit-slider-thumb { @include thumb; } &::-moz-range-thumb { @include thumb; }
}
Скриншот. Показывает высокий «ползунок» с фоновой картинкой и высокой узкой пурпурной «ручкой».
Вот что пока получается (пример)

Следующий шаг — добавить еще один слой background для «дорожки», а именно linear-gradient, в котором линия раздела между transparent и white зависит от текущего значения input-а, --k, и затем наложить один слой на другой.

@mixin track() { /* все прежние стили */ background: url(flowers.jpg) 50%/ cover, linear-gradient(90deg, transparent var(--p), white 0); background-blend-mode: exclusion;
} [type='range'] { /* все прежние стили */ --p: calc(var(--k) * 1%);
}

Обратите внимание, что порядок слоев background для «дорожки» не важен, поскольку оба режима exclusion и difference коммутативны.

Начинает что-то вырисовываться, но перетаскивание ползунка пока не двигает линию раздела. Это потому, что текущее значение, --k (от которого зависит положение разделительной линии градиента, --p), не обновляется автоматически. Исправим это одной строчкой JavaScript, которая берет значение ползунка при каждом его изменении и присваивает это значение --k.

addEventListener('input', e => { let _t = e.target; _t.style.setProperty('--k', +_t.value)
})

Теперь вроде всё работает!

See the Pen
1 element image vs. negative, step 3: auto-update separation line
by Ana Tudor (@thebabydino)
on CodePen.

Но правильно ли? Допустим, мы сделаем для фона «ручки» что-нибудь поинтереснее:

$thumb-r: .5*$thumb-w;
$thumb-l: 2px; @mixin thumb() { /* все прежние стили */ --list: #fff 0% 60deg, transparent 0%; background: conic-gradient(from 60deg, var(--list)) 0/ 37.5% /* левая стрелка */, conic-gradient(from 240deg, var(--list)) 100%/ 37.5% /* правая стрелка */, radial-gradient(circle, transparent calc(#{$thumb-r} - #{$thumb-l} - 1px) /* внутри окружности */, #fff calc(#{$thumb-r} - #{$thumb-l}) calc(#{$thumb-r} - 1px) /* линия окружности */, transparent $thumb-r /* вне окружности */), linear-gradient( #fff calc(50% - #{$thumb-r} + .5*#{$thumb-l}) /* верхняя линия */, transparent 0 calc(50% + #{$thumb-r} - .5*#{$thumb-l}) /* разрыв линии за кругом */, #fff 0 /* нижняя линия */) 50% 0/ #{$thumb-l}; background-repeat: no-repeat;
}

Функция linear-gradient() создает тонкую вертикальную разделительную линию, radial-gradient() рисует круг, а два слоя conic-gradient() создают стрелки.

See the Pen
1 element image vs. negative, step 4: fancier thumb
by Ana Tudor (@thebabydino)
on CodePen.

Проблему теперь сразу видно при перетягивании ползунка от одного края к другому: линия раздела не привязана к центральной вертикали «ручки».

Когда мы задаем для --p значение calc(var(--k)*1%), линия раздела движется от 0% до 100%. На самом деле ее крайние положения должны быть смещены от концов «дорожки» на половину ширины «ручки», $thumb-r. Т.е. она должна двигаться в диапазоне 100% минус ширина «ручки», $thumb-w. Отнимаем по половине от каждого конца, так что в итоге надо вычесть целую «ручку». Давайте исправим это!

--p: calc(#{$thumb-r} + var(--k) * (100% - #{$thumb-w}) / 100);

Намного лучше!

See the Pen
1 element image vs. negative, step 5: fix separation line position
by Ana Tudor (@thebabydino)
on CodePen.

Но инпуты-ползунки устроены так, что их border-box двигается в пределах content-box «дорожки» (Chrome) или в пределах фактического content-box элемента (Firefox)… так что это кажется неправильным. Было бы куда лучше, если бы середина «ручки» (а значит, и разделительная линия) могла ходить по всей ширине окна.

Мы не можем изменить устройство ползунка, но можем сделать, чтобы input выступал за края окна на половину ширины «ручки» влево и еще на половину ширины «ручки» вправо. Тогда его width станет равняться ширине окна, 100vw, плюс целая ширина ручки, $thumb-w.

body { overflow: hidden; } [type='range'] { /* все прежние стили */ margin-left: -$thumb-r; width: calc(100vw + #{$thumb-w});
}

Еще пара мелких улучшений насчёт cursor и готово!

See the Pen
1 element image vs. negative
by Ana Tudor (@thebabydino)
on CodePen.

Более эффектный вариант этого (вдохновленный сайтом Compressor.io) — поместить input внутрь карточки, которая слегка поворачивается в трех измерениях при движении курсора над ней.

See the Pen
Original vs. negative card (hover card, drag slider)
by Ana Tudor (@thebabydino)
on CodePen.

Можно сделать и вертикальный слайдер. Это немного сложнее, поскольку единственный надежный кроссбраузерный способ стилизации вертикальных слайдеров — это трансформация поворота, но она повернет и background. Поступим так: будем задавать значение --p и фоны контейнеру слайдера (который не повернут), а сам input и его дорожку сделаем полностью прозрачными (transparent).

Это можно увидеть в действии в примере ниже, где я инвертирую свой автопортрет в моем любимом худи с группой Kreator.

See the Pen
Vertical original vs. negative card (hover card, drag slider)
by Ana Tudor (@thebabydino)
on CodePen.

Само собой, для крутого эффекта можно использовать и radial-gradient():

background: radial-gradient(circle at var(--x, 50%) var(--y, 50%), #000 calc(var(--card-r) - 1px), #fff var(--card-r)) border-box, $img 50%/ cover;

В этом случае позиция, определяемая кастомными свойствами  --x and --y, вычисляется из координат курсора мыши над карточкой.

See the Pen
Uninvert kitty
by Ana Tudor (@thebabydino)
on CodePen.

Инвертированную область background можно создавать не только градиентом. Это может быть и область за текстом заголовка, как в этой более старой статье про контраст текста относительно фоновой картинки.

See the Pen
text/ background contrast with mix-blend-mode #1
by Ana Tudor (@thebabydino)
on CodePen.

Постепенная инверсия

У приема с наложением для инвертирования есть и другие преимущества перед использованием фильтров. С ним можно сделать так, чтобы эффект плавно нарастал по градиенту. Например, левая сторона совсем не инвертирована, а по мере приближения к правому краю эффект всё сильнее, вплоть до полной инверсии.

Чтобы понять, как добиться такого эффекта, сначала нужно понять действие invert(p), где p может быть любым значением в интервале [0%, 100%] (или [0, 1], если воспользоваться десятичным представлением).

Первый способ, который работает и для difference, и для exclusion — задать альфа-каналу нашей белой области значение p. Можно увидеть это в действии в примере ниже, где ползунок управляет степенью инверсии:

See the Pen
Inversion: via filter & via blending, side by side
by Ilya Streltsyn (@SelenIT)
on CodePen.

Если вас удивляет запись hsl(0, 0%, 100% / 100%), то это теперь валидный способ представления white с единицей в альфа-канале, как гласит спецификация.

Далее, из-за алгоритма работы filter: invert(p) в общем случае, т.е. пересчета значения каждого канала в сжатый интервал [Min(p, q), Max(p, q)], где q — дополнение для p (т.е. q = 1 - p) перед инвертированием (вычитанием его из 1), мы получим следующее выражение для произвольного канала Ch при частичной инверсии:

1 - (q + Ch·(p - q)) =
= 1 - (1 - p + Ch·(p - (1 - p))) =
= 1 - (1 - p + Ch·(2·p - 1)) =
= 1 - (1 - p + 2·Ch·p - Ch) =
= 1 - 1 + p - 2·Ch·p + Ch =
= Ch + p - 2·Ch·p

Получилась в точности формула для exclusion, где p — соотв. канал другого слоя!  Следовательно, того же эффекта, что с filter: invert(p) при любом p в интервале [0%, 100%], можно достичь с режимом наложения exclusion, где у другого слоя значение rgb(p, p, p).

Это значит, что можно сделать плавное нарастание инверсии вдоль linear-gradient(), от отсутствия инверсии у левого края до полной инверсии у правого, следующим кодом:

background: url(butterfly_blues.jpg) 50%/ cover, linear-gradient(90deg, #000 /* эквивалент rgb(0%, 0%, 0%) и hsl(0, 0%, 0%) */, #fff /* эквивалент rgb(100%, 100%, 100%) и hsl(0, 0%, 100%) */);
background-blend-mode: exclusion;
Скриншот оригинальной картинки с бабочкой слева и ее постепенного перетекания в негатив справа.
Постепенное нарастание инверсии слева направо (пример)

Заметьте, что градиент от black до white работает для постепенного нарастания инверсии только с режимом наложения exclusion, но не с difference. Результат работы difference для этого случая, согласно его формуле — «псевдоплавная» инверсия, у которой в середине не нейтральный серый (50%), а промежуточные RGB-значения, где каждый из трех каналов «обнуляется» в разных точках на градиенте. Поэтому контраст выглядит более резким. Возможно, это чуть более «художественно», но не в моей компетенции об этом судить.

Скриншот постепенного нарастания инверсии на картинке с бабочкой (с режимом наложения exclusion) и псевдопостепенно нарастающей инверсии на ней же (с режимом difference).
Постепенное нарастание инверсии и «псевдоинверсии» слева направо (пример)

Разные уровни инверсии в разных местах фона могут получаться не только из черно-белого градиента. Их можно получить и из черно-белой картинки, где черные области картинки сохранят background-color, белые области будут полностью инвертированы, а все промежуточные градации при наложении в режиме exclusion дадут частичную инверсию. С difference опять же получится более резкая картинка в двух тонах.

Посмотреть на это можно в следующем интерактивном примере, где можно менять background-color и перетягивать линию раздела между результатами этих двух режимов наложения.

See the Pen
Blend mode duotone
by Ana Tudor (@thebabydino)
on CodePen.

Эффект «пустоты» при пересечении

Основной принцип здесь в том, что у нас есть два слоя только с черными и белыми пикселями.

Расходящиеся круги и лучи

Рассмотрим элемент с двумя псевдоэлементами, у каждого их которых background  представляет собой повторяющийся CSS-градиент с резкими границами:

$d: 15em;
$u0: 10%;
$u1: 20%; div { &::before, &::after { display: inline-block; width: $d; height: $d; background: repeating-radial-gradient(#000 0 $u0, #fff 0 2*$u0); content: ''; } &::after { background: repeating-conic-gradient(#000 0% $u1, #fff 0% 2*$u1); }
}

В зависимости от браузера и экрана, края между black и white могут выглядеть рваными… а могут и нет.

Скриншот, показывающий рваные края между черными и белыми областями градиентов.
Рваные края (пример)

Ради подстраховки от этой проблемы можно подправить наши градиенты, добавив крошечное расстояние, $e, между областями black и white:

$u0: 10%;
$e0: 1px;
$u1: 5%;
$e1: .2%; div { &::before { background: repeating-radial-gradient( #000 0 calc(#{$u0} - #{$e0}), #fff $u0 calc(#{2*$u0} - #{$e0}), #000 2*$u0); } &::after { background: repeating-conic-gradient( #000 0% $u1 - $e1, #fff $u1 2*$u1 - $e1, #000 2*$u1); }
}
Скриншот, показывающий сглаженные края между черными и белыми областями градиентов.
Гладкие края (пример)

Теперь можно наложить их друг на друга и задать mix-blend-mode со значением exclusion или difference, здесь их результат будет один и тот же.

div { &::before, &::after { /* остальные стили те же самые, за вычетом display, ставшего лишним */ position: absolute; mix-blend-mode: exclusion; }
}

Во всех черных областях верхнего слоя результат наложения идентичен другому слою, независимо от того, белая или черная область там была. Таким образом, black поверх black дает black, а black поверх white дает white.

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

Однако, в зависимости от браузера, фактический результат может выглядеть либо как задумано (в Chromium), либо так, будто ::before наложился на сероватый background, который мы задали для body, и уже на полученный результат наложился ::after (Firefox, Safari).

Коллаж из скриншотов. Слева ожидаемый черно-белый результат, что-то вроде XOR между кольцами радиального градиента и лучами конического градиента — такое получается в Chrome. Справа тот же результат, наложившийся на светло-серый фон — это получается в Firefox и Safari.
Chromium 87 (слева): результат выглядит как нужно. Firefox 83 и Safari 14 (справа): мутный результат из-за наложения на слой body (пример)

Поведение Chromium — это баг, но именно такой результат нам нужен. И мы можем получить его и в Firefox с Safari, либо задав свойство isolation со значением isolate для родительского div (пример), либо убрав объявление mix-blend-mode у ::before (тогда операция наложения его на body останется дефолтным normal, что означает отсутствие смешивания цветов) и указав его только для ::after (пример).

Конечно, можно также всё упростить и накладывать друг на друга два слоя background элемента, а не его псевдоэлементы. Для этого надо будет перейти с mix-blend-mode на background-blend-mode.

$d: 15em;
$u0: 10%;
$e0: 1px;
$u1: 5%;
$e1: .2%; div { width: $d; height: $d; background: repeating-radial-gradient( #000 0 calc(#{$u0} - #{$e0}), #fff $u0 calc(#{2*$u0} - #{$e0}), #000 2*$u0), repeating-conic-gradient( #000 0% $u1 - $e1, #fff $u1 2*$u1 - $e1, #000 2*$u1); background-blend-mode: exclusion;
}

Это дает точно такой же визуальный результат, но избавляет от необходимости в псевдоэлементах, от возможных нежелательных побочных эффектов mix-blend-mode в Firefox and Safari, а также сокращает объем нужного CSS-кода.

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

Разделенный экран

Общая идея в том, что у нас есть сцена с черной и белой половинами, и белая фигура движется с одной половины на другую. Затем слой с фигурой и слой со сценой накладываются друг на друга с помощью difference или exclusion (они дадут одинаковый результат).

Если фигура — это, например, «мяч», то простейший способ получить такой результат — использовать для нее radial-gradient, а для сцены linear-gradient, а затем анимировать background-position, чтобы мяч «катался» туда-сюда.

$d: 15em; div { width: $d; height: $d; background: radial-gradient(closest-side, #fff calc(100% - 1px), transparent) 0/ 25% 25% no-repeat, linear-gradient(90deg, #000 50%, #fff 0); background-blend-mode: exclusion; animation: mov 2s ease-in-out infinite alternate;
} @keyframes mov { to { background-position: 100%; } }
Анимированный гиф. Показывает белый «мяч», перекатывающийся влево и вправо и накладывающийся с эффектом XOR на фон, у когорого половина белая (и мяч на ней становится черным), а половина черная (на ней мяч остается белым).
«Катающийся мяч» (пример)

Можно также сделать сцену псевдоэлементом ::before, а движущуюся фигуру — псевдоэлементом ::after:

$d: 15em; div { display: grid; width: $d; height: $d; &::before, &::after { grid-area: 1/ 1; background: linear-gradient(90deg, #000 50%, #fff 0); content: ''; } &::after { place-self: center start; padding: 12.5%; border-radius: 50%; background: #fff; mix-blend-mode: exclusion; animation: mov 2s ease-in-out infinite alternate; }
} @keyframes mov { to { transform: translate(300%); } }

Может показаться, что мы слегка перемудрили (учитывая, что визуальный результат тот же), но на самом деле именно это нам понадобится, если фигура — не просто диск, а что-то посложнее, и не просто движется туда-сюда, а вдобавок вращается и меняет размер.

$d: 15em;
$t: 1s; div { /* все прежние стили */ &::after { /* все прежние стили */ /* создаем фигуру, подробности этого выходят за рамки этой статьи */ @include poly; /* анимации */ animation: t $t ease-in-out infinite alternate, r 2*$t ease-in-out infinite, s .5*$t ease-in-out infinite alternate; }
} @keyframes t { to { translate: 300% } }
@keyframes r { 50% { rotate: .5turn; } 100% { rotate: 1turn;; }
}
@keyframes s { to { scale: .75 1.25 } }
Анимированный гиф. Показывает белый треугольник, движущийся влево и вправо (одновременно вращаясь и сжимаясь) и накладывающийся с эффектом XOR на фон, у когорого половина белая (и треугольник на ней становится черным), а половина черная (на ней треугольник остается белым).
Движущаяся и вращающаяся пластичная фигура (пример)

Учтите, что хотя Firefox, а с недавних пор и Safari, уже поддерживают отдельные свойства для трансформаций, которые мы здесь анимируем, в Chrome они всё ещё за флагом «Экспериментальные функции веб-платформы» (его можно включить в chrome://flags, как показано ниже).

Скриншот, показывающий флаг «Экспериментальные функции веб-платформы», включенный в Chrome.
Флаг «Экспериментальные функции веб-платформы» в Chrome включен.

Еще примеры

Мы не будем вникать в подробности того, как сделаны эти примеры, поскольку базовая идея эффекта наложения с помощью exclusion или difference всё та же, а нюансы геометрии и анимации выходят за рамки этой статьи. Но для каждого примера ниже есть ссылка на CodePen в подписи, и для многих из них доступно видео, как я пишу их код с нуля.

Вот анимация скрещивающихся полос, которую я недавно сделала на основе гифки Bees & Bombs:

4 квадрата, размещенные в форме креста, попарно оказываются половинами изломанных полос. Они снова объединяются в целые полосы и вытягиваются в длину, затем эти полосы поворачиваются и накладываются друг на друга с XOR-эффектом, образуя изначальную крестообразную фигуру.
Скрещивающиеся полосы (пример)

А вот анимация кольца из «половинок луны» из недавнего прошлого, тоже по мотивам гифки Bees & Bombs:

Анимированный гиф. Показывает 12 «лун в фазе последней четверти», расположенных по окружности так, что они пересекаются с XOR-эффектом. На своем месте каждая из них вращается, начальный угол поворота зависит от ее места на окружности, тем самым области пересечения тоже поворачиваются.
Половинки луны (пример)

Нам не обязательно ограничиваться только черным и белым. С помощью фильтра contrast cо значением меньше единицы (filter: contrast(.65) в примере ниже) для обертки мы можем превратить черный в темно-серый, а белый в светло-серый:

Анимированный гиф. Начинается с четырех треугольников, оставнихся по углам квадрата, из середины которого вырезан другой квадрат с вершинами на серединах сторон внешнего. Это оказывается результатом XOR-операции внутреннего квадрата с двумя треугольными половинами внешнего квадрата. Эти треугольные половины расходятся в стороны, поворачиваются на 45 градусов и уменьшаются, пока их катеты не сравняются со сторонами внутреннего квадрата, а их гипотенузы не окажутся своими серединами на его противоположных вершинах, перпендикулярно диагонали. Затем внутренний квадрат делится пополам по другой диагонали и эти половины расходятся прямыми углами наружу, пока снова не получится изначальная фигура.
Открытие: два квадрата — четыре треугольника (пример, исходник)

Вот еще один пример того же самого приема:

Анимированный гиф. Начинается с 8 треугольников, которые получаются при XOR-наложении двух квадратов, повернутых на 45 градусов относительно друг друга. 1-й, 2-й, 5-й и 6-й треугольники сдвигаются внутрь, образуя два квадрата под углом 45 градусов друг к другу, при XOR-эффекте снова образующие исходную фигуру. Остальные треугольники сдвигаются наружу и исчезают.
Восемь треугольников (пример, исходник)

Если нам нужен такой же «XOR-эффект» (по аналогии с оператором XOR — прим. перев.), но для черных фигур на белом фоне, можно применить filter: invert(1) для обертки этих фигур, как в примере ниже:

Анимированный гиф. Начинается с 4 полос вдоль сторон квадрата, снаружи него. Они сдвигаются внутрь, пока противоположные полосы не сомкнутся друг с другом. Их пересечение с эффектом XOR снова дает изначальную фигуру.
Четыре полосы (пример, исходник)

А если нам нужно что-то менее резкое, вроде темно-серых фигур на светло-сером фоне, то вместо полной инверсии можно ограничиться частичной. Это подразумевает фильтр invert со значением меньше единицы, например, в примере ниже мы используем filter: invert(.85):

Анимированный гиф. Начинается с 6 треугольников, получающихся при вырезании из 6-конечной звезды шестиугольника, образованного внутренними 6 вершинами. 2 противоположных треугольника увеличиваются и движутся навстречу друг другу, пока их пересечение не даст в итоге изначальную фигуру, а 4 оставшиеся движутся наружу и уменьшаются до нуля.
Шесть треугольников (пример, исходник)

Не обязательно это должна быть зацикленная анимация или что-то вроде индикатора загрузки. Такой XOR-эффект может быть и между фоном элемента и его рамкой, сдвинутой со своего обычного положения. Как и в прошлых примерах, используем инверсию с CSS-функцией filter, если фон и рамка должны быть черными, а их область пересечения — белой.

Скриншот. Показывает квадратные блоки с текстом, пересекающиеся с XOR-эфеектом с их сдвинутыми рамками, цвет которых совпадает с цветом фона блока (черный либо белый).
Сдвинутая и «вырезанная XOR-эффектом» рамка (пример).

Еще один пример — XOR-эффект при наведении/фокусе и клике по кнопке закрытия. Пример внизу показывает варианты как в темной, так и в светлой теме:

See the Pen
Tile closing effect
by Ana Tudor (@thebabydino)
on CodePen.

Придаем больше жизни

Картина в одних черно-белых оттенках может показаться чуть унылой, так что есть несколько способов придать таким примерам чуть больше жизни.

Первая тактика — применить фильтры. Можно вырваться из рамок черного и белого, добавив фильтр sepia() после уменьшения контраста (поскольку эта функция не действует на чистые black и white). Выбрать оттенок с помощью hue-rotate() и подогнать brightness() и saturate() либо contrast() до нужного результата.

Например, взяв один из предыдущих черно-белых примеров, мы можем применить к обертке такую цепочку фильтров:

filter: contrast(.65) /* превращаем черный и белый в серый */ sepia(1) /* коричневатый тон, как у старых фото */ hue-rotate(215deg) /* меняем оттенок с коричневатого на пурпурный */ blur(.5px) /* сглаживаем края */ contrast(1.5) /* увеличиваем насыщенность */ brightness(5) /* делаем намного ярче фон */ contrast(.75); /* притеняем треугольники (чтоб не были ярко-белыми) */
Анимированный гиф. Начинается с четырех треугольников, оставнихся по углам квадрата, из середины которого вырезан другой квадрат с вершинами на серединах сторон внешнего. Это оказывается результатом XOR-операции внутреннего квадрата с двумя треугольными половинами внешнего квадрата. Эти треугольные половины расходятся в стороны, поворачиваются на 45 градусов и уменьшаются, пока их катеты не сравняются со сторонами внутреннего квадрата, а их гипотенузы не окажутся своими серединами на его противоположных вершинах, перпендикулярно диагонали. Затем внутренний квадрат делится пополам по другой диагонали и эти половины расходятся прямыми углами наружу, пока снова не получится изначальная фигура.
Открытие: два квадрата — четыре треугольника, версия поживее (пример)

Для еще большего контроля над результатом всегда есть SVG-фильтры как вариант.

Вторая тактика — добавить еще один слой, не черно-белый. Например, в этом примере «радиоактивного пирога», который я сделала для первого мартовского CodePen-челленджа, я использовала пурпурный псевдоэлемент ::before для body и наложила на него обертку «пирога».

body, div { display: grid; } /* складываем всё стопкой в одну грид-ячейку */
div, ::before { grid-area: 1/ 1; } body::before { background: #7a32ce; } /* пурпурный слой */ /* применяется и к кусочкам «пирога», и к обертке */
div { mix-blend-mode: exclusion; } .a2d { background: #000; } /* черная обертка */ .pie { background: /* переменный размер белых кусочков */ conic-gradient(from calc(var(--p)*(90deg - .5*var(--sa)) - 1deg), transparent, #fff 1deg calc(var(--sa) + var(--q)*(1turn - var(--sa))), transparent calc(var(--sa) + var(--q)*(1turn - var(--sa)) + 1deg));
}

Это превращает черную обертку в пурпурную, а белые части — в зеленые (т.е. инвертированный пурпурный цвет, его «негатив»).

Анимированный гиф. Начинаеется с 9 круглых «пирогов», наложенных друг на друга с XOR-эффектом (такое пересечение нечетного числа одинаковых объектов дает один такой объект). Они постепенно разъезжаются в стороны и уменьшаются до секторов в 1/9 круга, из которых затем составляется полный круг. Затем всё повторяется.
Радиоактивные кусочки 🥧 (пример)

Еще один вариант — слить всю обертку с еще одним слоем, на этот раз с другим режимом наложения вместо  difference или exclusion. Это даст нам больше контроля над результатом, так что мы не будем ограничены одной парой дополнительных цветов (вроде черного и белого, или пурпурного и зеленого). Однако это мы рассмотрим в будущих статьях.

Наконец, есть еще одно применение для difference (но не exclusion), что когда два идентичных (не обязательно белых) слоя перекрываются, получается черный цвет. Например, разность между coral и coral всегда даст 0 во всех трех каналах, т.е. black. Это значит, что можно адаптировать пример вроде рамки со сдвигом и XOR-эффектом и получить вот такой результат:

Скриншот. Показывает квадратные блоки с текстом, пересекающиеся с XOR-эфеектом с их сдвинутыми рамками, цвет которых совпадает с цветом фона блока (не обязательно черный либо белый).
Рамка со сдвигом и XOR-эффектом — версия поживее (пример).

Установив несколькими свойствами прозрачный (transparent) цвет рамке и обрезку фона, можно заставить это работать даже с градиентными фонами:

Скриншот. Показывает квадратные блоки с текстом, пересекающиеся с XOR-эфеектом с их сдвинутыми рамками, которые залиты тем же градиентом, что фон блока.
Рамка со сдвигом и XOR-эффектом — градиентная версия (пример).

По аналогии, можно даже взять вместо градиента картинку!

Скриншот. Показывает квадратные блоки с текстом, пересекающиеся с XOR-эфеектом с их сдвинутыми рамками, которые залиты той же картинкой, что фон блока.
Рамка со сдвигом и XOR-эффектом — версия с картинкой (пример).

Обратите внимание, что в этом случае нам придется инвертировать фоновую картинку, когда мы инвертируем весь элемент для второй темы (напр. светлой). Но это не проблема, ведь в этой статье мы уже научились это делать: задаем для background-color значение white и накладываем на него слой с картинкой с помощью background-blend-mode: exclusion!

Заключительные мысли

Лишь с этими двумя режимами наложения мы можем добиться замечательных результатов, не прибегая к canvas, SVG или дублированию слоев. Но это только капля в море их возможностей. В будущих статьях мы разберем, как работают другие режимы наложения, и чего можно достичь с их помощью, самих по себе или в сочетании с предыдущими, а также с другими визуальными эффектами CSS, вроде фильтров. И поверьте мне, чем больше трюков будет у вас в арсенале, тем более поразительных эффектов вы сможете добиться!

P.S. Это тоже может быть интересно: