7 способов вырезать отверстие в градиенте

Опубликовано
22 декабря 2025 г.
Обновлено
22 декабря 2025 г.
Play

Я проходил собеседование, и мне предложили сделать тестовое задание. Нужно было сделать приложение Apple Погода по дизайну из фигмы c использованием React, Typescript и любых библиотек, которые могли понадобится. Приложение должно запрашивать данные из любого провайдера погоды. Обычное задание, ничего особенного, но был момент который я не знал как правильно реализовать, а точнее вот этот компонент залитый градиентов с вырезом под белой точкой.

У меня было 3 идеи, как это можно сделать:

  1. Сделать у точки border цветом фона под градиентом.
  2. Использовать CSS свойство mix-blend-mode.
  3. Или сделать с помощью 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 только здесь мы можем использовать значения которые, раньше посчитали. Давай разберём, что тут происходит.

Два важных момента перед тем как начнём разбирать:

  1. В CSS ось X направлена вправо, а ось Y — вниз. Значит, положительные значения Y — ниже оси X, а отрицательные — выше.
  2. В path() буквы — это команды: переместиться в точку, провести линию, нарисовать дугу и т.д. Если буквы заглавные то используются абсолютные координаты, если строчные — относительные. Во всех примерах в этом видео мы будем использовать только абсолютные координаты.

Чтобы наглядно показать, как строится путь для clip-path, я сделаю ещё один элемент с градиентом. На верхнем элементе покажу, как формируется путь, а снизу — итоговый результат после применения clip-path. Область, залитая красным, будет видимой. Для того чтобы белая точка тебя не отвлекала я её уберу в верхнем элементе.

  1. Сначала переходим в точку (0, 0) — верхний левый угол элемента.

  2. Потом рисуем горизонтальную линию вправо на всю ширину. Пока высота контура нулевая, поэтому мы ничего не видим.

  3. Проводим вертикальную линию вниз на всю высоту элемента. Последняя точка автоматически замыкается с начальной и элемент обрезается по диагонали.

  4. Дальше проводим горизонтальную линию в координату 0 по оси X.

  5. Замыкаем контур. Получается прямоугольник: всё что внутри видно, а всё снаружи — скрыто.

  6. Теперь вырезаем отверстие. Переходим в верхнюю точку окружности — по Y она будет отрицательной, то есть выше верхней границы элемента (потому что диаметр отверстия больше высоты элемента с градиентом).

Чтобы получить окружность, рисуем две дуги:

  1. первая дуга с радиусом r идёт в нижнюю точку.
  2. вторую дугу, с таким же радиусом, проводим обратно, в верхнюю точку окружности.

Поздравляю! Мы вырезали отверстие в градиенте 🙂

Код для 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.

Поддержка функции clip-path: 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>:

  1. Первый способ - использовать его в CSS с помощью свойства clip-path и функции url в которой мы ссылаемся на SVG-элемент <clipPath> (clip-path: url(#svgClipPath)). Для этого у элемента <clipPath> должен быть атрибут id, чтобы на него можно было сослаться из CSS. В нашем случае id - это svgClipPath.
  1. Второй способ - применить <clipPath> прямо к SVG-элементу. Для этого у SVG-элемента используем атрибут clip-path в котором мы так же, как и в CSS, ссылаемся на элемент <clipPath> (clip-path="url(#svgClipPath)". Если использовать этот способ, тогда нужно по другому задавать градиент для SVG-элемента. Т.к. в свойстве fill мы не можем использовать функции градиента, нужно создать SVG-элемент <linearGradient>, который уже используем в атрибуте fill SVG-элемента.

Чтобы всё корректно работало при ресайзе окна, нужно обновлять значение атрибута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%
);

Поделись статьёй с друзьями