Мне часто задают вопрос: Возможно ли создать тени из градиентов, а не из сплошных цветов? В С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). Это позволит мне легко задать маску — последний шаг для этого трюка.


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));
}

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


Ссылка на 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);
}


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


Вот и всё!


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


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


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


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




Комментарии (4)


  1. Kaiwas
    00.00.0000 00:00

    Благодарствую!

    Было интересно.


  1. custod
    00.00.0000 00:00

    Хотя результат выглядит, прямо скажем, весьма сомнительно по сравнению с тем, что можно получить в фотошопе или гимпе, статья таки полезная, как еще один экзерсис на css. Никогда не знаешь, что может пригодиться по ходу жизни :)


  1. altervision
    00.00.0000 00:00
    +3

    — Почему на вашем сайте мой компьютер начинает завывать вентилятором как реактивный истребитель?

    — Наш дизайнер захотел градиентные тени …

    — А вы его бить не пробовали?

    — Пробовали - он их анимировать начинает.


    1. apxi
      00.00.0000 00:00

      Тоже всегда вызывают возмущения такие извращения с css.