Разные способы создать градиентную тень на CSS

alexei29/03/2023 - 17:50
Разные способы создать градиентную тень на CSS

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

Но постойте, скажите вы, еще одна статья о трюках с градиентными тенями?

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

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

Решение для непрозрачного элемента

Давайте начнем с решения, которое будет работать в 80% случаев. Наиболее типичный случай: вы используете элемент с фоном, и вам нужно добавить к нему тень с градиентом. Здесь нет проблем с прозрачностью.

Решение состоит в использовании псевдоэлемента, в котором и определяется градиент. Вы помещаете его за фактическим элементом и применяете к нему фильтр размытия.


.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 к основному элементу, и бум! Тень больше не находится под элементом:

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

Первое решение, которое мы рекомендуем, это использование 3D трансформации:


.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 мы будем использовать отрицательную трансляцию по оси Z. Мы поместим все значения в свойство translate3d(). Не забудьте для основного элемента установить transform-style: preserve-3d; в противном случае 3D трансформация не заработает.

Если по какой-то причине вы не можете использовать 3D трансформацию, то другим решением будет использование двух псевдоэлементов - ::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 или любое другое свойство, которое делает то же самое. Кроме того, стоить помнить, что псевдоэлементы рассматривают поле отступов основного элемента по ссылке. Поэтому, если у основного элемента есть рамки, то при определении стилей псевдоэлементов вы должны это учитывать. В коде можно заметить, что для псевдоэлемента ::after мы используем inset: -2px, чтобы учесть рамку, определенную в основном элементе.

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

Решение для прозрачного элемента

Давайте продолжим с того места, где мы имели дело с 3D трансформацией, и удалим фон у основного элемента. Начнем с тени, у которой и смещения и радиус растяжения равны 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 для элемента
Координаты 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))
  );
}

Мы определили переменную CSS --s для радиуса растяжения и обновили точки многоугольника. Мы не трогали точки, где используется большое значение. Мы обновили только те точки, которые определяют углы псевдоэлемента. Мы увеличиваем все нулевые значения на --s и уменьшаем 100% значения на --s.

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


.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 из координаты X и --y из координаты Y.

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

А нам все еще нужно использовать трюк с 3D трансформацией?

Все зависит от рамки. Не забывайте про особенности с полем отступов для псевдоэлемента, поэтому, если вы определите рамку в основном элементе, вы получите перекрывающее наложение. Тогда вам придется либо использовать трюк с 3D трансформацией, либо задать такое значение для inset, которое будет учитывать рамку.

Вот предыдущий пример с обновленным значением для inset вместо 3D трансформации:

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

В случае с непрозрачным элементом, возможно, вы столкнетесь с проблемой контекста стекирования. А в случае с прозрачным элементом, вместо этого, возможно, вы столкнетесь с проблемой рамок. Теперь вы знаете способы обойти эти проблемы. Трюк с 3D трансформацией, как правило, решает все эти проблемы.

Добавление скругленных углов

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

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

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

И здесь нам придется познакомиться со свойством mask.

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

Чтобы упростить это решение, мы решили вставить дополнительный HTML элемент:


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

Чтобы избежать любого потенциального конфликта с внешним CSS, мы добавляем пользовательский элемент <sh>. Мы могли бы использовать и элемент <div>, но поскольку это стандартный элемент HTML, на него может быть нацелено другое правило 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);
}

Как вы можете видеть, псевдоэлемент использует тот же код, что и все предыдущие примеры. Единственное отличие заключается в том, что 3D трансформация определяется для элемента <sh>, а не для псевдоэлемента. На данный момент у нас есть градиентная тень без учета прозрачности:

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

Наверное, все это выглядит немного сложно, но в отличие от clip-path, при отображении и скрытии объектов свойство mask не учитывает область вне элемента. Поэтому для имитации "внешней" области мы должны были ввести дополнительный элемент.

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

Еще одна полезная вещь, которую мы получаем от использования дополнительного элемента, заключается в том, что элемент фиксирован, и только псевдоэлемент перемещается (с помощью 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.

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


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

А есть ли какие-либо недостатки у этого метода?

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

Исправить это относительно просто: добавьте ширину рамки к значению свойства 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);
}

Подводим итоги

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

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