Укрощаем режимы наложения: 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-image
— background-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
Название этого режима (переводится «разница» или «разность») может подсказать, что делает функция наложения f
B
()
. Результат — это абсолютное значение разности между значениями соответствующих каналов для двух слоёв.
Ch = fB(Chs, Chd) = |Chs - Chd|
Прежде всего, это значит, что если у соответствующих пикселей в двух слоях идентичные RGB-значения (т.е. Ch
s
= Ch
d
для всех трех каналов), то итоговый пиксель будет 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
в верхнем слое (источнике), то, заменив в этой формуле Ch
s
на 0
, мы получим такой результат для каналов соответствующего пикселя результата:
Ch = fB(0, Chd) = 0 + Chd - 2·0·Chd = Chd - 0 = Chd
Если взять пиксель со значением black
в нижнем слое (цели), то, заменив в этой формуле Ch
d
на 0
, мы получим такой результат для каналов соответствующего пикселя результата:
Ch = fB(Chs, 0) = Chs + 0 - 2·Chs·0 = Chs - 0 = Chs
Итак, если у пикселя в одном слое значение black
, то соответствующий пиксель результата идентичен пикселю другого слоя.
Если взять пиксель со значением white
в верхнем слое (источнике), то, заменив в этой формуле Ch
s
на 1
, мы получим такой результат для каналов соответствующего пикселя результата:
Ch = fB(1, Chd) = 1 + Chd - 2·1·Chd = 1 + Chd - 2·Chd = 1 - Chd
Если взять пиксель со значением white
в нижнем слое (цели), то, заменив в этой формуле Ch
d
на 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
на будущее, когда его пофиксят.
Отлично, но нам нужно, чтобы это происходило только при наведении или под фокусом. В других случаях псевдоэлемент невидим — скажем, отмасштабирован в ноль.
a { /* все прежние стили */ text-decoration: none; &::after { /* все прежние стили */ transform: scale(0); } &:focus { outline: none } &:focus, &:hover { &::after { transform: none; } } }
Заодно мы убрали у ссылки подчеркивание и рамку фокуса. Ниже вы можете увидеть эффект разности при :hover
(тот же эффект происходит и при :focus
, что можно проверить в живом примере).
Теперь состояние меняется, но слишком резко, так что добавим-ка transition
!
a { /* все прежние стили */ &::after { /* все прежние стили */ transition: transform .25s; } }
Намного лучше!
Было бы еще лучше, если бы псевдоэлемент вырастал не из точки в центре, а из тонкой линии внизу. Это значит, что нам надо задать transform-origin
по нижнему краю (на 100%
по вертикали и где угодно по горизонтали) и изначально уменьшить псевдоэлемент чуть-чуть меньше чем до нуля по оси y.
a { /* все прежние стили */ &::after { /* все прежние стили */ transform-origin: 0 100%; transform: scaleY(.05); } }
Еще я бы поменяла font
абзаца на более эстетически притягательный, так что давайте позаботимся и об этом! Но теперь у нас другая проблема: при :focus
/:hover
«хвостик» буквы «d» торчит из прямоугольника наружу.
Это можно исправить горизонтальным padding
-ом для нашей ссылки.
a { /* все прежние стили */ padding: 0 .25em; }
Если вас удивило, почему мы задаем этот padding
и справа и слева, а не только padding-right
, вот иллюстрация причины. Если текст ссылки изменится на «Alien World», без padding-left
выгнутое начало от «A» в итоге окажется снаружи прямоугольника.
Этот же пример с многословной ссылкой показывает и еще одну проблему при уменьшении ширины окна.
Быстрым фиксом тут может быть display: 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
.
Уже намного лучше, но тоже пока не идеально.
Первую проблему вы наверняка заметили, если открыли ссылку на пример из подписи к картинке в Firefox: оно просто не работает. Это из-за бага в Firefox, который я, судя по всему, сама завела еще в 2018-м, но напрочь о нем забыла, пока не стала играть с режимами наложения и опять на него наткнулась.
Вторую проблему видно на записи с экрана. Ссылки выглядят какими-то блеклыми. Это из-за того, что Chrome почему-то накладывает строчные элементы вроде ссылок (заметьте, что с блочными элементами вроде дивов этого не происходит) на background
их ближайшего предка (в данном случае section
), если у этих строчных элементов любое значение background-blend-mode
, кроме normal
.
Что еще более странно, задание isolation: isolate
для ссылки или ее родительского абзаца от этого не спасает. Меня до сих пор гложет чувство, что это как-то связано с контекстами наложения, так что я решила попробовать перебрать все возможные хаки, авось что-нибудь да сработает. Что ж, долго возиться не пришлось. Значение opacity
меньше единицы (но достаточно близко к 1
, чтобы элемент по-прежнему выглядел полностью непрозрачным) решает проблему.
a { /* все прежние стили */ opacity: .999; /* хак для борьбы с «блеклостью» ¯_(ツ)_/¯ */ }
Последнюю проблему тоже можно заметить на записи. Если присмотреться к последней букве в слове «Amur», можно увидеть, что ее правый край обрезан, поскольку выступает за край фонового прямоугольника. Это особенно заметно в сравнении с буквой «r» в слове «leopard».
Я не особо надеялась ее решить, но всё равно задала вопрос в Твиттере. И что бы вы думали, решение нашлось! С помощью box-decoration-break
в сочетании с padding
, который мы уже задали, нужный эффект достигается!
a { /* все прежние стили */ box-decoration-break: clone; }
Обратите внимание, что для box-decoration-break всё еще нужен префикс
-webkit-
во всех вебкитных браузерах, но в отличие от свойств типаbackground-clip
в случаях, когда хотя бы одно из значений равноtext
(т.е. стандартное свойство с нестандартным, но повсеместно поддерживаемым значением — прим. перев.), с этим прекрасно справляются автоматические средства расстановки префиксов. Поэтому я не пишу в этом коде версию с префиксом.
Еще мне подсказали добавить отрицательный margin
, чтоб компенсировать padding
. Я пробовала и так, и сяк — не могу решить, как лучше, с ним или без. В любом случае, упомянуть такой вариант стоит.
$p: .25em; a { /* все прежние стили */ margin: 0 (-$p); /* пишем в скобках, чтобы Sass не принял эту запись за вычитание */ padding: 0 $p; }
И всё же признаюсь, что анимация одних лишь background-position
или background-size
для градиента выглядит скучновато. Но благодаря Houdini теперь можно не ограничивать полет фантазии и анимировать какой угодно компонент градиента, пусть даже пока это работает только в Chromium. Например, радиус для radial-gradient()
как в примере ниже или «процент заполнения» для conic-gradient()
.
Инвертировать только часть элемента (или фона)
Я часто вижу реализацию подобного эффекта с помощью дублирования элемента. Иногда копии элемента наложены одна поверх другой, и для верхней применяются 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
.
Вам наверняка знакомы интерактивные слайдеры картинок «было — стало». Возможно, вы даже видели такое на самом 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-значения, где каждый из трех каналов «обнуляется» в разных точках на градиенте. Поэтому контраст выглядит более резким. Возможно, это чуть более «художественно», но не в моей компетенции об этом судить.
Разные уровни инверсии в разных местах фона могут получаться не только из черно-белого градиента. Их можно получить и из черно-белой картинки, где черные области картинки сохранят 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).
Поведение 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-кода.
Разделенный экран
Общая идея в том, что у нас есть сцена с черной и белой половинами, и белая фигура движется с одной половины на другую. Затем слой с фигурой и слой со сценой накладываются друг на друга с помощью 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%; } }
Можно также сделать сцену псевдоэлементом ::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 } }
Учтите, что хотя Firefox, а с недавних пор и Safari, уже поддерживают отдельные свойства для трансформаций, которые мы здесь анимируем, в Chrome они всё ещё за флагом «Экспериментальные функции веб-платформы» (его можно включить в chrome://flags
, как показано ниже).
Еще примеры
Мы не будем вникать в подробности того, как сделаны эти примеры, поскольку базовая идея эффекта наложения с помощью exclusion или difference всё та же, а нюансы геометрии и анимации выходят за рамки этой статьи. Но для каждого примера ниже есть ссылка на CodePen в подписи, и для многих из них доступно видео, как я пишу их код с нуля.
Вот анимация скрещивающихся полос, которую я недавно сделала на основе гифки Bees & Bombs:
А вот анимация кольца из «половинок луны» из недавнего прошлого, тоже по мотивам гифки Bees & Bombs:
Нам не обязательно ограничиваться только черным и белым. С помощью фильтра contrast
cо значением меньше единицы (filter: contrast(.65)
в примере ниже) для обертки мы можем превратить черный в темно-серый, а белый в светло-серый:
Вот еще один пример того же самого приема:
Если нам нужен такой же «XOR-эффект» (по аналогии с оператором XOR — прим. перев.), но для черных фигур на белом фоне, можно применить filter: invert(1)
для обертки этих фигур, как в примере ниже:
А если нам нужно что-то менее резкое, вроде темно-серых фигур на светло-сером фоне, то вместо полной инверсии можно ограничиться частичной. Это подразумевает фильтр invert со значением меньше единицы, например, в примере ниже мы используем filter: invert(.85)
:
Не обязательно это должна быть зацикленная анимация или что-то вроде индикатора загрузки. Такой XOR-эффект может быть и между фоном элемента и его рамкой, сдвинутой со своего обычного положения. Как и в прошлых примерах, используем инверсию с CSS-функцией filter
, если фон и рамка должны быть черными, а их область пересечения — белой.
Еще один пример — 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); /* притеняем треугольники (чтоб не были ярко-белыми) */
Для еще большего контроля над результатом всегда есть 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)); }
Это превращает черную обертку в пурпурную, а белые части — в зеленые (т.е. инвертированный пурпурный цвет, его «негатив»).
Еще один вариант — слить всю обертку с еще одним слоем, на этот раз с другим режимом наложения вместо difference
или exclusion
. Это даст нам больше контроля над результатом, так что мы не будем ограничены одной парой дополнительных цветов (вроде черного и белого, или пурпурного и зеленого). Однако это мы рассмотрим в будущих статьях.
Наконец, есть еще одно применение для difference
(но не exclusion
), что когда два идентичных (не обязательно белых) слоя перекрываются, получается черный цвет. Например, разность между coral
и coral
всегда даст 0
во всех трех каналах, т.е. black
. Это значит, что можно адаптировать пример вроде рамки со сдвигом и XOR-эффектом и получить вот такой результат:
Установив несколькими свойствами прозрачный (transparent) цвет рамке и обрезку фона, можно заставить это работать даже с градиентными фонами:
По аналогии, можно даже взять вместо градиента картинку!
Обратите внимание, что в этом случае нам придется инвертировать фоновую картинку, когда мы инвертируем весь элемент для второй темы (напр. светлой). Но это не проблема, ведь в этой статье мы уже научились это делать: задаем для background-color
значение white
и накладываем на него слой с картинкой с помощью background-blend-mode: exclusion
!
Заключительные мысли
Лишь с этими двумя режимами наложения мы можем добиться замечательных результатов, не прибегая к canvas, SVG или дублированию слоев. Но это только капля в море их возможностей. В будущих статьях мы разберем, как работают другие режимы наложения, и чего можно достичь с их помощью, самих по себе или в сочетании с предыдущими, а также с другими визуальными эффектами CSS, вроде фильтров. И поверьте мне, чем больше трюков будет у вас в арсенале, тем более поразительных эффектов вы сможете добиться!
P.S. Это тоже может быть интересно:
Я бы ничего не понял из этой статьи…Блин поражаюсь как программисты со всем этим справляются.Эти коды….У дизайнеров и то работы легче)А вот у кого зарплата больше не знаю…Говорят сейчас самые большие гонорары и создателей мобильных приложений
Здравствуй, друг!
Хочешь узнать что-то новое и интересное по фронденту? Тогда тебе к нам!
В нашем сообществе ты сможешь найти:
1) Полезные статьи, что помогут тебе в развитии;
2) Разнообразные розыгрыши (последние были носки и почему то все хотели их);
3) Сложные задачи и пути их решения;
4) Опросы, чтобы узнать более актуальную информацию;
5) Также можно получать достижения;
Заходи к нам ТЛГ deveveloper_house_jun_front
Большое спасибо за внятное объяснение. Очень помогло.