Градиентные тени на CSS

от автора

Мне часто задают вопрос: Возможно ли создать тени из градиентов, а не из сплошных цветов? В СSS не существует конкретного свойства для этого (поверьте мне, я проверял), а в любом посте по этой теме содержится только множество хитростей для того, чтобы получить что-то похожее на градиент. В этой статье я расскажу вам о некоторых из них.

Но для начала… ещё одна статья о градиентных тенях? Серьёзно?

Да, это ещё одна статья на эту тему, но она отличается от других. Вместе мы раздвинем границы возможного, чтобы получить решение, включающее в себя то, чего я раньше нигде не видел, — прозрачность. Большинство подходов работают, когда у элемента непрозрачный фон, но что, если он прозрачный?

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

Решение без поддержки прозрачности

Начнём с решения, которое работает в 80% случаев. Чаще всего используется элемент с фоном, и к нему нужно добавить градиентную тень. Прозрачности здесь не будет.

Решение полагается на псевдоэлемент, где задаётся градиент. Расположите его за основным элементом и примените к нему фильтр blur (размытие).

.box {   position: relative; } .box::before {   content: "";   position: absolute;   inset: -5px; /* управляет расстоянием рассеивания */   transform: translate(10px, 8px); /* управляет смещением */   z-index: -1; /* располагает элемент позади основного */   background: /* здесь находится ваш градиент */;   filter: blur(10px); /* управляет размытием */ }

Похоже, здесь много кода. Вот как можно сделать то же самое с box-shadow, если вместо градиента использовать сплошной цвет.

box-shadow: 10px 8px 10px 5px orange;

Теперь вы понимаете, что делают значения в первом фрагменте кода. Там находятся смещения по осям X и Y, радиус и расстояние рассеивания. Заметьте, что при указании расстояния рассеивания, которое берётся из свойства inset, используется отрицательное значение.

В этом демо рядом с классическим box-shadow можно увидеть градиентную тень:

Если присмотреться, можно заметить, что эти тени немного различаются, особенно в том, что касается размытия. Это неудивительно. Я уверен, что алгоритмы свойства filter работают иначе, чем алгоритмы box-shadow. Это не проблема, ведь в итоге получается почти то же самое.

Решение хорошее, но у него есть ряд недостатков, связанных с z-index: -1: здесь есть так называемый «контекст наложения»!

Ссылка на Pen

Я применил transform к основному элементу и — вуаля! Тень больше не под элементом. Это не баг, а логичный результат работы контекста наложения. Не беспокойтесь, я не буду докучать объяснением работы контекста наложения. Я уже всё объяснил в обсуждении на Stack Overflow), но всё же покажу вам, как такого избежать.

Первое, что я советую использовать, — решение при помощи трёхмерного transform:

.box {   position: relative;   transform-style: preserve-3d; } .box::before {   content: "";   position: absolute;   inset: -5px;   transform: translate3d(10px, 8px, -1px); /* (X, Y, Z) */   background: /* .. */;   filter: blur(10px); }

Вместо z-index: -1 применяется translate c отрицательным значением по оси Z. Всё находится внутри translate3d(). Не забудьте к основному элементу применить transform-style: preserve-3d, иначе трёхмерный transform не будет работать.

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

Если же по какой-то причине вы не можете использовать трёхмерный transform, можно задействовать два псевдоэлемента: ::before и ::after. Один создаёт градиентную тень, а другой — основной фон и другие, которые могут вам понадобиться. Таким образом, можно легко управлять порядком наложения обоих псевдоэлементов.

.box {   position: relative;   z-index: 0; /* Заставляем создать контекст наложения */ } /* Создаётся тень */ .box::before {   content: "";   position: absolute;   z-index: -2;   inset: -5px;   transform: translate(10px, 8px);   background: /* .. */;   filter: blur(10px); } /* Создаются стили основного элемента */ .box::after {   content: """;   position: absolute;   z-index: -1;   inset: 0;   /* Наследуются все стили основного элемента */   background: inherit;   border: inherit;   box-shadow: inherit; }

Необходимо отметить, что мы заставляем основной элемент создать контекст наложения при помощи z-index: 0 или любого другого свойства с тем же эффектом для этого элемента. Псевдоэлементы отсчитывают от padding box элемента: если у основного элемента есть граница, при создании стилей псевдоэлементов нужно принять это во внимание. Заметьте, что я использую inset: -2px в ::after для того, чтобы учесть границу, заданную для основного элемента.

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

Решение с поддержкой прозрачности

Продолжим с того места, где мы остановились, работая с трёхмерным transform, и удалим фон основного элемента. Я начну с тени, у которой и смещения, и расстояние рассеивания равняются 0.

Ссылка на Pen

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

Конечно, это невозможно, но мы имитируем такое поведение особым паттерном с применением полигона:

clip-path: polygon(-100vmax -100vmax,100vmax -100vmax,100vmax 100vmax,-100vmax 100vmax,-100vmax -100vmax,0 0,0 100%,100% 100%,100% 0,0 0)

Ссылка на Pen

Ура! Теперь у нас есть градиентная тень с поддержкой прозрачности. Всё, что мы сделали, — добавили к нашему коду clip-path. На рисунке изображена схема работы полигона.

Синяя область видна после применения clip-path. Для демонстрации этого принципа я использую синий цвет, но внутри этой области видна только тень. У нас есть четыре точки, определённые большими значениями (B). Я использую 100vmax, но можно применять любое большое значение на ваш выбор. Идея в том, чтобы гарантировать, что хватит места для тени. Также здесь есть четыре точки — это углы псевдоэлемента.

Стрелки показывают путь, определяющий полигон. Начинаем с (-B, -B) и идём, пока не достигнем (0,0). Всего нужно 10 точек, а не 8, потому что две точки повторяются на пути дважды ((-B,-B) и (0,0)).

Есть ещё кое-что: нужно учесть расстояние рассеивания и смещения. Демо выше работает только потому, что это частный случай, где расстояние рассеивания и смещение равняются 0.

Давайте определим рассеивание и посмотрим, что случится. Напомню, что для этого используется inset с отрицательным значением:

Ссылка на Pen

Псевдоэлемент стал больше основного элемента, а clip-path обрезает больше, чем нужно. Помните, всегда нужно обрезать часть внутри основного элемента (область внутри зелёной рамки на примере выше). Следует также изменить расположение четырёх точек внутри clip-path.

.box {   --s: 10px; /* рассеивание  */   position: relative; } .box::before {   inset: calc(-1 * var(--s));   clip-path: polygon(     -100vmax -100vmax,      100vmax -100vmax,      100vmax 100vmax,     -100vmax 100vmax,     -100vmax -100vmax,     calc(0px  + var(--s)) calc(0px  + var(--s)),     calc(0px  + var(--s)) calc(100% - var(--s)),     calc(100% - var(--s)) calc(100% - var(--s)),     calc(100% - var(--s)) calc(0px  + var(--s)),     calc(0px  + var(--s)) calc(0px  + var(--s))   ); }

Мы ввели СSS-переменную --s, задающую расстояние рассеивания, и обновили точки полигона. Я не менял точки, где используется большое значение. Обновились только точки, определяющие углы псевдоэлемента. Ещё я увеличил все нулевые значения на --s и уменьшил все 100% на --s.

Ссылка на Pen

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

.box {   --s: 10px; /* рассеивание */   --x: 10px; /* смещение по оси X */   --y: 8px;  /* смещение по оси Y */   position: relative; } .box::before {   inset: calc(-1 * var(--s));   transform: translate3d(var(--x), var(--y), -1px);   clip-path: polygon(     -100vmax -100vmax,      100vmax -100vmax,      100vmax 100vmax,     -100vmax 100vmax,     -100vmax -100vmax,     calc(0px  + var(--s) - var(--x)) calc(0px  + var(--s) - var(--y)),     calc(0px  + var(--s) - var(--x)) calc(100% - var(--s) - var(--y)),     calc(100% - var(--s) - var(--x)) calc(100% - var(--s) - var(--y)),     calc(100% - var(--s) - var(--x)) calc(0px  + var(--s) - var(--y)),     calc(0px  + var(--s) - var(--x)) calc(0px  + var(--s) - var(--y))   ); }

Здесь добавились ещё две переменные для смещений: --x и --y. Они находятся внутри transform. Обновляются значения clip-path. Большие значения всё ещё не применяются к точкам полигона, но все остальные точки смещаются: мы вычитаем --x из координат по оси Х, а --y — из координат по оси Y.

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

Нужно ли применять хитрость с трёхмерным transform?

Зависит от границы. Не забудьте, что псевдоэлемент отсчитывается от padding box. Нужно либо сохранить трёхмерный transform, либо обновить значение inset, чтобы учитывать границу.

Вот предыдущее демо с обновлённым значением inset вместо трёхмерного transform:

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

Используя первое решение без поддержки прозрачности, можно столкнуться с проблемой контекста наложения. А применяя решение с поддержкой прозрачности, можно столкнуться с проблемой границы. Теперь вы знаете, как избежать этих проблем. Хитрость с трёхмерным transform — моё любимое решение, так как с его помощью можно избежать любых проблем (онлайн-генератор также учитывает этот момент).

Добавление радиуса скругления

Добавить border-radius к элементу, используя первоначальное решение без поддержки прозрачности, достаточно просто. Нужно всего лишь унаследовать то же значение от основного элемента, и всё.

Даже если border-radius нет, неплохо прописать border-radius: inherit. Так можно учесть любой возможный border-radius, который вы можете добавить в будущем, или border-radius откуда-то ещё.

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

Работать будем со свойством mask.

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

Для простоты я решил добавить дополнительный элемент:

<div class="box">   <sh></sh> </div>

Я использую пользовательский элемент <sh>, чтобы избежать любых возможных конфликтов с внешним CSS. Можно было использовать <div>, но на него может повлиять стороннее CSS-правило, что приведёт к неправильной работе кода.

Во-первых, нужно расположить <sh> и специально организиовать переполнение:

.box {   --r: 50px;   position: relative;   border-radius: var(--r); } .box sh {   position: absolute;   inset: -150px;   border: 150px solid #0000;   border-radius: calc(150px + var(--r)); }

Код может показаться немного странным, но дальше вы поймёте, что к чему. Создаётся градиентная тень с помощью псевдоэлемента <sh>.

.box {   --r: 50px;   position: relative;   border-radius: var(--r);   transform-style: preserve-3d; } .box sh {   position: absolute;   inset: -150px;   border: 150px solid #0000;   border-radius: calc(150px + var(--r));   transform: translateZ(-1px) } .box sh::before {   content: "";   position: absolute;   inset: -5px;   border-radius: var(--r);   background: /* Ваш градиент */;   filter: blur(10px);   transform: translate(10px,8px); }

Как видите, псевдоэлемент содержит тот же код, что и во всех примерах. Разница в том, что трёхмерный transform находится в <sh>, а не в псевдоэлементе. Сейчас градиентная тень непрозрачна:

Ссылка на Pen

Заметьте, что область элемента <sh> находится внутри чёрного контура. Зачем? Таким образом можно применить к ней mask, чтобы скрыть часть внутри зелёного контура, и сохранить часть с переполнением, где будет находиться тень.

Знаю, это немного сложно, но в отличие от clip-path свойство mask не учитывает область снаружи элемента для отображения и скрытия объектов. Вот почему мне пришлось ввести дополнительный элемент — для симуляции «внешней» области.

Также стоит отметить, что для задания области я использую сочетание border и inset. Такой подход позволяет сохранить padding-box этого дополнительного элемента таким же, как у основного элемента, и псевдоэлементу не требуются дополнительные вычисления.

Ещё одно преимущество дополнительного элемента заключается в том, что этот элемент фиксирован, а движется только псевдоэлемент (при помощи [translate](https://css-tricks.com/almanac/properties/t/transform/#aa-translate)). Это позволит мне легко задать маску — последний шаг для этого трюка.

mask:   linear-gradient(#000 0 0) content-box,   linear-gradient(#000 0 0); mask-composite: exclude;

Готово! Градиентная тень работает и поддерживает border-radius! Возможно, вы думали, что значение mask будет сложным с большим количеством градиентов, но нет! Для этого трюка требуется только два простых градиента и [mask-composite](https://css-tricks.com/almanac/properties/m/mask-composite/).

Изолируем элемент <sh>, чтобы понять, что тут происходит:

.box sh {   position: absolute;   inset: -150px;   border: 150px solid red;   background: lightblue;   border-radius: calc(150px + var(--r)); }

Вот что получается:

Ссылка на Pen

Обратите внимание, что внутренний радиус равен border-radius основного элемента. Я задал длинную границу (150px) и border-radius, равный длинной границе плюс радиусу основного элемента. Снаружи радиус равен 150px + R, а внутри — 150px + R - 150px = R.

Необходимо скрыть внутреннюю (синюю) часть и убедиться, что часть с границей (красная) по-прежнему видима. Для этого я задал два слоя масок: один занимает только область content-box, другой — область border-box (значение по умолчанию). Потом я исключил один из другого, чтобы показать границу.

mask:   linear-gradient(#000 0 0) content-box,   linear-gradient(#000 0 0); mask-composite: exclude;

Ссылка на Pen

Тот же подход используется и для границы с поддержкой градиентов и border-radius. Ана Тюдор написала хорошую статью о mask-composite. Приглашаю ознакомиться с ней.

Есть ли минусы у этого подхода?

Да, он точно неидеален. Во-первых, вы можете столкнуться с проблемой при использовании границы на основном элементе. Если не обратить на это внимание, радиусы могут немного не совпасть. В примере эта проблема есть, но она едва заметна.

И её достаточно просто исправить: к свойству inset элемента <sh> добавьте ширину границы.

.box {   --r: 50px;   border-radius: var(--r);   border: 2px solid; } .box sh {   position: absolute;   inset: -152px; /* 150px + 2px */   border: 150px solid #0000;   border-radius: calc(150px + var(--r)); }

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

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

.box {   --r-top: 10px;   --r-right: 40px;   --r-bottom: 30px;   --r-left: 20px;   border-radius: var(--r-top) var(--r-right) var(--r-bottom) var(--r-left); } .box sh {   border-radius: calc(150px + var(--r-top)) calc(150px + var(--r-right)) calc(150px + var(--r-bottom)) calc(150px + var(--r-left)); } .box sh:before {   border-radius: var(--r-top) var(--r-right) var(--r-bottom) var(--r-left); }

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

Вот и всё!

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

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

И, конечно же, есть онлайн-генератор, чтобы не тратить силы впустую.

Только полезная теория и ещё больше практики на наших курсах:


ссылка на оригинал статьи https://habr.com/ru/company/skillfactory/blog/718732/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *