Мне часто задают вопрос: Возможно ли создать тени из градиентов, а не из сплошных цветов? В С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
: здесь есть так называемый «контекст наложения»!
Я применил 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
.
Идея в том, чтобы найти способ обрезать или спрятать всё, что находится в области основного элемента (внутри зелёной рамки), и оставить всё, что находится вне этой области. Для этого воспользуемся 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)
Ура! Теперь у нас есть градиентная тень с поддержкой прозрачности. Всё, что мы сделали, — добавили к нашему коду clip-path
. На рисунке изображена схема работы полигона.
Синяя область видна после применения clip-path
. Для демонстрации этого принципа я использую синий цвет, но внутри этой области видна только тень. У нас есть четыре точки, определённые большими значениями (B
). Я использую 100vmax
, но можно применять любое большое значение на ваш выбор. Идея в том, чтобы гарантировать, что хватит места для тени. Также здесь есть четыре точки — это углы псевдоэлемента.
Стрелки показывают путь, определяющий полигон. Начинаем с (-B, -B)
и идём, пока не достигнем (0,0)
. Всего нужно 10 точек, а не 8, потому что две точки повторяются на пути дважды ((-B,-B)
и (0,0)
).
Есть ещё кое-что: нужно учесть расстояние рассеивания и смещения. Демо выше работает только потому, что это частный случай, где расстояние рассеивания и смещение равняются 0
.
Давайте определим рассеивание и посмотрим, что случится. Напомню, что для этого используется inset
с отрицательным значением:
Псевдоэлемент стал больше основного элемента, а 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
.
Та же логика применяется и при работе со смещением. Когда на псевдоэлементе используется 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>
, а не в псевдоэлементе. Сейчас градиентная тень непрозрачна:
Заметьте, что область элемента <sh>
находится внутри чёрного контура. Зачем? Таким образом можно применить к ней mask
, чтобы скрыть часть внутри зелёного контура, и сохранить часть с переполнением, где будет находиться тень.
Знаю, это немного сложно, но в отличие от clip-path
свойство mask
не учитывает область снаружи элемента для отображения и скрытия объектов. Вот почему мне пришлось ввести дополнительный элемент — для симуляции «внешней» области.
Также стоит отметить, что для задания области я использую сочетание border
и inset
. Такой подход позволяет сохранить padding-box этого дополнительного элемента таким же, как у основного элемента, и псевдоэлементу не требуются дополнительные вычисления.
Ещё одно преимущество дополнительного элемента заключается в том, что этот элемент фиксирован, а движется только псевдоэлемент (при помощи translate). Это позволит мне легко задать маску — последний шаг для этого трюка.
mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
mask-composite: exclude;
Готово! Градиентная тень работает и поддерживает border-radius
! Возможно, вы думали, что значение mask
будет сложным с большим количеством градиентов, но нет! Для этого трюка требуется только два простых градиента и mask-composite.
Изолируем элемент <sh>
, чтобы понять, что тут происходит:
.box sh {
position: absolute;
inset: -150px;
border: 150px solid red;
background: lightblue;
border-radius: calc(150px + var(--r));
}
Вот что получается:
Обратите внимание, что внутренний радиус равен 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;
Тот же подход используется и для границы с поддержкой градиентов и 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);
}
Для простоты онлайн-генератор работает только с единым радиусом, но теперь вы знаете, как нужно изменить код для учёта сложного радиуса.
Вот и всё!
Теперь вы понимаете магию, скрытую за градиентными тенями. Я постарался рассказать о всех вариантах и возможных проблемах. Если я что-то упустил, если обнаружите какую-то ошибку — не стесняйтесь написать о ней в комментариях, и я проверю, всё ли в порядке.
Повторю, что часть информации в статье вряд ли вам понадобится, ведь простое решение будет работать в большинстве случаев. Но всё же лучше понимать, «как» и «почему» всё работает и как справиться с ограничениями. А ещё это неплохое упражнение по обрезке и маскированию.
И, конечно же, есть онлайн-генератор, чтобы не тратить силы впустую.
Только полезная теория и ещё больше практики на наших курсах:
Data Science и Machine Learning
- Профессия Data Scientist
- Профессия Data Analyst
- Курс «Математика для Data Science»
- Курс «Математика и Machine Learning для Data Science»
- Курс по Data Engineering
- Курс «Machine Learning и Deep Learning»
- Курс по Machine Learning
Python, веб-разработка
- Профессия Fullstack-разработчик на Python
- Курс «Python для веб-разработки»
- Профессия Frontend-разработчик
- Профессия Веб-разработчик
Мобильная разработка
Java и C#
- Профессия Java-разработчик
- Профессия QA-инженер на JAVA
- Профессия C#-разработчик
- Профессия Разработчик игр на Unity
От основ — в глубину
А также