7 способов вырезать отверстие в градиенте
Я проходил собеседование, и мне предложили сделать тестовое задание. Нужно было сделать приложение Apple Погода по дизайну из фигмы c использованием React, Typescript и любых библиотек, которые могли понадобится. Приложение должно запрашивать данные из любого провайдера погоды. Обычное задание, ничего особенного, но был момент который я не знал как правильно реализовать, а точнее вот этот компонент залитый градиентов с вырезом под белой точкой.
У меня было 3 идеи, как это можно сделать:
- Сделать у точки
borderцветом фона под градиентом. - Использовать CSS свойство
mix-blend-mode. - Или сделать с помощью 3-х элементов: 2 элемента с градиентами и между ними белая точка.
Сначала я попробовал сделать иллюзию выреза через mix-blend-mode, но результат вообще не был похож на то, что нужно.
С вариантом из трёх элементов всё получалось хорошо, но когда дошло до закругления градиента в месте, где он соприкасается с вырезом, я понял, что не знаю, как это сделать.
Так как времени было немного, а других идей не было решил использовать border с цветом фона под градиентом.
Но меня не покидал вопрос: как всё-таки сделать это лучше? И в один из дней мне пришло в голову сразу несколько идей. В этом видео я покажу 7 способов как можно сделать такой компонент.
clip-path: path()
Первая идея — использовать CSS свойство clip-path. С помощью этого свойства можно вырезать фигуру из элемента, к которому оно применено.
У clip-path есть несколько функций, с помощью которых задаётся контур фигуры. То, что внутри контура, будет видно, а всё, что снаружи — скрыто. Здесь нам нужна функция path(). Она позволяет описать путь с помощью команд, по которому будет и будет строиться вырез.
Вот так выглядит CSS, который вырезает фигуру которая нам нужна.
.clip--path {
clip-path: path(
evenodd,
"M 0 0 H 489 V 40 H 0 Z M 61.125 -20 A 40 40 0 1 0 61.125 60 A 40 40 0 1 0 61.125 -20"
);
}
Первое, что бросается в глаза: читать это тяжело, а про то чтобы это писать вручную я вообще молчу.
Если начать ресайзить окно, станет видно, что clip-path не адаптируется под ширину вьюпорта. Это потому что в path() можно использовать только пиксели. Функции и CSS переменные path() не поддерживает.
И тут нам на помощь приходит JS.
Давай пробежимся по коду и разберём, что он делает.
import { getHoleData } from "../utils/getHoleData.js";
const fillElement = document.querySelectorAll(".clip--path");
function updateClipPath(el) {
const width = el.clientWidth;
const height = el.clientHeight;
const { x, yTop, yBottom, r } = getHoleData(el);
const path = [
"M 0 0",
`H ${width}`,
`V ${height}`,
"H 0",
"Z",
`M ${x} ${yTop}`,
`A ${r} ${r} 0 1 0 ${x} ${yBottom}`,
`A ${r} ${r} 0 1 0 ${x} ${yTop}`,
].join(" ");
el.style.clipPath = `path(evenodd, "${path}")`;
}
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
if (!entry.target) {
return;
}
updateClipPath(entry.target);
}
});
fillElement.forEach((el) => {
observer.observe(el);
updateClipPath(el);
});
Сначала находим элемент с градиентом и подписываемся на изменения его размеров через ResizeObserver. При каждом изменении размеров будет вызываться функция updateClipPath. В этой функции мы берём ширину и высоту элемента с градиентом. Из функции getHoleData получаем все необходимые данные для отверстия, которое хотим вырезать:
x- это координаты центра окружности по оси X,yTop- это координата верхней точки окружности по оси Y,- yBottom - координата нижней точки окружности по оси Y,
r- это радиус окружности.
Дальше мы формируем путь. Это тот же путь что мы видели в CSS только здесь мы можем использовать значения которые, раньше посчитали. Давай разберём, что тут происходит.
Два важных момента перед тем как начнём разбирать:
- В CSS ось X направлена вправо, а ось Y — вниз. Значит, положительные значения Y — ниже оси X, а отрицательные — выше.
- В
path()буквы — это команды: переместиться в точку, провести линию, нарисовать дугу и т.д. Если буквы заглавные то используются абсолютные координаты, если строчные — относительные. Во всех примерах в этом видео мы будем использовать только абсолютные координаты.
Чтобы наглядно показать, как строится путь для clip-path, я сделаю ещё один элемент с градиентом. На верхнем элементе покажу, как формируется путь, а снизу — итоговый результат после применения clip-path. Область, залитая красным, будет видимой. Для того чтобы белая точка тебя не отвлекала я её уберу в верхнем элементе.
-
Сначала переходим в точку (0, 0) — верхний левый угол элемента.
-
Потом рисуем горизонтальную линию вправо на всю ширину. Пока высота контура нулевая, поэтому мы ничего не видим.
-
Проводим вертикальную линию вниз на всю высоту элемента. Последняя точка автоматически замыкается с начальной и элемент обрезается по диагонали.
-
Дальше проводим горизонтальную линию в координату 0 по оси X.
-
Замыкаем контур. Получается прямоугольник: всё что внутри видно, а всё снаружи — скрыто.
-
Теперь вырезаем отверстие. Переходим в верхнюю точку окружности — по Y она будет отрицательной, то есть выше верхней границы элемента (потому что диаметр отверстия больше высоты элемента с градиентом).
Чтобы получить окружность, рисуем две дуги:
- первая дуга с радиусом
rидёт в нижнюю точку. - вторую дугу, с таким же радиусом, проводим обратно, в верхнюю точку окружности.
Поздравляю! Мы вырезали отверстие в градиенте 🙂
Код для path() не самый интуитивный, зато у него хорошая поддержка браузеров.
clip-path: shape()
Но у clip-path есть и функция с более удобным синтаксисом — это функция shape(). Она поддерживает не только пиксели, но и другие единицы измерений (например, проценты). Так же в ней можно использовать математические функции такие как calc() и CSS переменные. То есть мы можем сделать то же самое, что мы делали в функции path(), но на чистом CSS.
Вот как это выглядит в CSS.
.clip--shape {
--x: var(--hole-position);
--r: var(--hole-radius);
--yTop: calc(var(--r) / -2);
--yBottom: calc(var(--r) * 1.5);
clip-path: shape(
evenodd from 0 0,
hline to 100%,
vline to 100%,
hline to 0,
close,
move to var(--x) var(--yTop),
arc to var(--x) var(--yBottom) of var(--r),
arc to var(--x) var(--yTop) of var(--r)
);
}
В shape() команды пишутся словами и разделяются запятыми. Если нужны абсолютные координаты — после команды используем to, если относительные — by.
Если сравнить с path(), смысл тот же, но читать shape() гораздо проще. И главное — здесь уже можно использовать проценты и значения из CSS-переменных.
Важно: поддержка shape() пока не идеальная. Вот как обстоят дела на декабрь 2025.

В Chromium-браузерах (Chrome/Edge) и Safari она уже есть, а в Firefox она есть только в Nightly версии, если включить фичефлаг.
SVG <clipPath>
Помимо CSS-свойства clip-path есть и отдельный SVG-элемент <clipPath>.
Чтобы задать путь внутри <clipPath> нужно создать элемент <path> у которого в атрибуте d описывать команды пути — по нему и будет выполняться вырезание.
<svg viewBox="0 0 1000 40" class="scale__svg">
<defs>
<clipPath id="svgClipPath">
<path clip-rule="evenodd" d="M 0 0 H 489 V 40 H 0 Z M 61.125 -20 A 40 40 0 1 0 61.125 60 A 40 40 0 1 0 61.125 -20" />
</clipPath>
</defs>
</svg>
Альтернативный вариант — задавать d как CSS-свойство, но важно помнить, что Safari это не поддерживает.
#svgClipPath path {
d: path(
"M 0 0 H 489 V 40 H 0 Z M 61.125 -20 A 40 40 0 1 0 61.125 60 A 40 40 0 1 0 61.125 -20"
);
}
Есть два способа использовать <clipPath>:
- Первый способ - использовать его в CSS с помощью свойства
clip-pathи функцииurlв которой мы ссылаемся на SVG-элемент<clipPath>(clip-path: url(#svgClipPath)). Для этого у элемента<clipPath>должен быть атрибутid, чтобы на него можно было сослаться из CSS. В нашем случаеid- этоsvgClipPath.
- Второй способ - применить
<clipPath>прямо к SVG-элементу. Для этого у SVG-элемента используем атрибутclip-pathв котором мы так же, как и в CSS, ссылаемся на элемент<clipPath>(clip-path="url(#svgClipPath)". Если использовать этот способ, тогда нужно по другому задавать градиент для SVG-элемента. Т.к. в свойствеfillмы не можем использовать функции градиента, нужно создать SVG-элемент<linearGradient>, который уже используем в атрибутеfillSVG-элемента.
Чтобы всё корректно работало при ресайзе окна, нужно обновлять значение атрибутаviewBox у SVG и значение атрибут d у элемента <path> внутри <clipPath>.
Canvas
Мы разобрали два способа с использованием CSS свойства clip-path и один — с использованием SVG-элемента <clipPath>. Есть ещё один вариант сделать то же самое — через функцию clip() в Canvas API.
Давай посмотрим, как это работает.
import { getHoleData } from "./utils/getHoleData.js";
const fillElement = document.querySelector(".canvas");
function updateClipPath(el) {
const width = el.clientWidth;
const height = el.clientHeight;
const dpr = window.devicePixelRatio || 1;
el.width = width * dpr;
el.height = height * dpr;
const { x, r } = getHoleData(el);
const ctx = el.getContext("2d");
ctx.scale(dpr, dpr);
const gradient = ctx.createLinearGradient(0, r / 2, width, r / 2);
gradient.addColorStop(0, "#d7e05e");
gradient.addColorStop(1, "#f28b2d");
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.rect(0, 0, width, height);
ctx.arc(x, r / 2, r, 0, 2 * Math.PI, true);
ctx.clip("evenodd");
ctx.fillRect(0, 0, width, height);
}
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
if (!entry.target) {
return;
}
updateClipPath(entry.target);
}
});
observer.observe(fillElement);
updateClipPath(fillElement);
Для начала нужен элемент <canvas>: мы зальём его градиентом и вырежем в нём отверстие.
Чтобы градиент подстраивался под ширину вьюпорта, создаём ResizeObserver и подписываемся на изменения размеров <canvas>. В колбэке ResizeObserver вызываем функцию, которая пересоздаёт градиент и обновляет вырез.
Перед тем как создавать градиент нужно задать внутренние размеры <canvas>. Обрати внимание, что ширину и высоту мы задаём с учётом devicePixelRatio. Это потому что внутри у <canvas> своё разрешение с использованием реальных пикселей, а не логических, как в CSS и через CSS мы его не можем изменить. Чтобы градиент выглядел чётко на экранах с высокой плотностью пикселей, мы масштабируем <canvas> на значение devicePixelRatio.
Дальше создаём градиент и заливаем им <canvas>. После этого вырезаем отверстие: строим путь для того, что нужно убрать, и вызываем функцию clip().
Команды для построения пути очень похожи на те, что мы использовали в функции path() в примере с clip-path.
В <canvas> окружность можно нарисовать одной командой arc(), а в функции path() для clip-path нам пришлось собирать окружность из двух дуг. Это из-за того что в path() нельзя указать одну и туже же точку для начала и конца дуги.
CSS mask
Следующий способ — использовать CSS-маску. Давай разберёмся, как работают маски CSS.
Для маски нам нужен источник — например, изображение или градиент. В нашем примере для маски используется радиальный градиент с отверстием в точке, где в элементе с градиентом должен появиться вырез. Этот радиальный градиент работает как трафарет: где маска прозрачная — элемент скрывается, где непрозрачная — остаётся видимым. Если в маске есть полупрозрачные участки, то и соответствующая часть элемента станет полупрозрачной. Например, если сделать маску видимой на 75%, то и градиент под ней будет выглядеть так, будто у него тоже opacity 75%.
Мы можем изменить то как работают маски с помощью свойств mask-typeи mask-mode. Например, если добавить элементу mask-mode: luminance, то при вычислении маски будет учитываться не только прозрачность, но и яркость цветов. Если в маске используется белый цвет — область под этим цветом будет максимально видна, а если чёрный — область будет скрыта.
CSS mask-composite
Есть альтернативный способ сделать то же самое через mask-composite. Это свойство определяет, как несколько масок комбинируются между собой.
Например, у нас две маски: linear-gradient, который покрывает весь элемент, и radial-gradient, который задаёт область будущего выреза. С помощью mask-composite можно “вычесть” одну маску из другой, используя значение exclude. exclude скрывает пересекающиеся области между масками и оставляет видимыми только непересекающиеся области.
Если вместо exclude использовать значение subtract, на первый взгляд результат будет таким же. Но если мы поменяем порядок масок то результат изменится и мы не увидим градиент. subtract вычитает пересечение масок и оставляет только то, что остаётся от первой маски.
Поэтому в случае когда первая маска полностью внутри второй, то мы не увидим ничего.
SVG <mask>
Последний способ — использовать SVG-маску. Давай создадим нужный для этого SVG-элемент.
В SVG есть элемент <mask>, который как раз и предназначен для маскирования. Внутри у него создаём элементы <rect> для выделения всего градиента и элемент <circle> для отверстия. Чтобы применить эту маску к элементу с градиентом нужно использовать CSS свойство mask-image где указываем маску как референс. Для этого нужно чтобы у элемента <mask> был атрибут id.
Для правильной работы при ресайзе окна используем код, который мы уже несколько раз видели раньше. Там меняем viewBox у SVG и атрибут cx — координата где находится центр элемента <circle> на оси X.
К сожалению на данный момент этот способ не работает в Safari так как Safari не поддерживают реферсы на SVG-маски в CSS свойстве mask-image.
Заключение
Это все способы, которые я нашёл, чтобы сделать вырез в градиенте. Если знаешь другие варианты — обязательно напиши в комментарии к видео. Мне будет интересно узнать как ещё это можно реализовать. Спасибо за просмотр!
Ссылки
Update
После публикации мне подсказали в комментарии к видео что можно сделать проще с помощью 3-х элементов с масками и на чистом CSS. Вот как это выглядит:
/* маска для левого элемента */
mask-image: radial-gradient(
circle 40px at calc(100% + 20px) 50%,
transparent 99%,
black 100%
);
/* маска для правого элемента */
mask-image: radial-gradient(
circle 40px at -20px 50%,
transparent 99%,
black 100%
);