DevGang
Авторизоваться

Магия clip-path

clip-path часто используется для обрезки узла DOM в определенные формы, например треугольники. Но что, если я скажу вам, что он также отлично подходит для анимации?

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

Основы

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

.circle {
  clip-path: circle(50% at 50% 50%);
}

Это не влияет на макет, а это означает, что элемент с clip-path будет занимать то же пространство, что и элемент без него, как и transform.

Позиционирование

Мы расположили наш круг выше, используя систему координат. Он начинается в верхнем левом углу (0, 0). circle(50% at 50% 50%) означает, что круг будет иметь радиус границы 50% и будет расположен на 50% сверху и на 50% слева, что является центром элемента.

Существуют и другие значения, такие как ellipsepolygon или даже url() которые позволяют нам использовать собственный SVG в качестве контура обрезки, но мы собираемся сосредоточиться на inset, поскольку именно его мы будем использовать для всех анимаций в этом посте.

Значения вставки определяют верхнее, правое, нижнее и левое смещение прямоугольника. Это означает, что если мы используем inset(100%, 100%, 100%, 100%) или inset(100%) в качестве сокращения, мы «скрываем» (обрезаем) весь элемент. Вставка (0px 50% 0px 0px) сделает левую половину элемента невидимой и так далее.

Теперь мы знаем, что clip-path может по существу «скрывать» части элемента, это открывает множество возможностей для анимации. Давайте начнем их открывать.

Слайдеры сравнения

Уверен, что вы где-то видели эти слайдеры «до» и «после». Есть много способов создать его, например, мы можем создать два элемента div со скрытым переполнением и изменить их ширину, но мы также можем использовать более производительный подход с помощью clip-path.

Начнем с наложения двух изображений друг на друга. Затем мы создаем объект clip-path: (0 50% 0 0), который скрывает правую половину верхнего изображения, и корректируем его в зависимости от положения перетаскивания.

clip-path: inset(0 60% 0 0);

Таким образом, мы получаем аппаратно-ускоренное взаимодействие без дополнительных элементов DOM (нам нужен дополнительный элемент для расширения, спрятанный в отсеке).

Знание того, что мы можем создать такой образец слайдера, clip-path открывает двери для многих других вариантов использования. Мы могли бы использовать его, например, для эффекта текстовой маски. 

Мы снова накладываем два элемента друг на друга, но на этот раз мы формируем нижнюю половину пунктирного текста с помощью clip-path: inset(0 0 50% 0), верхнюю половину сплошного текста с помощью clip-path: inset(50% 0 0 0). Затем мы корректируем эти значения в зависимости от положения мыши.

Пунктирный текст:

clip-path: inset(0 0 50% 0);

Заполненный текст:

clip-path: inset(50% 0 0 0);

Технически это тоже слайдер вертикального сравнения, но он на него не похож. Это просто вопрос творчества. 

Пунктирный текст здесь — это обводка, примененная в Figma, которая затем преобразуется в SVG.

Анимация изображений

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

.image-reveal {
  clip-path: inset(0 0 100% 0);
  animation: reveal 1s forwards cubic-bezier(0.77, 0, 0.175, 1);
}
 
 
@keyframes reveal {
  to {
    clip-path: inset(0 0 0 0);
  }
}

Мы также могли бы сделать это с помощью анимации высоты, но здесь есть некоторые преимущества clip-path. Оно имеет аппаратное ускорение, поэтому оно более производительно, чем анимация высоты изображения. Использование clip-path также собственного размещения макета при открытии изображения, поскольку изображение уже есть, оно просто обрезано.

Прокрутка анимации

Эффект раскрытия изображения должен быть создан, когда изображение опускается в область просмотра, в противном случае пользователь никогда не увидит анимируемое изображение. Так как же нам это сделать? 

Обычно я использую Framer Motion для анимации, поэтому показываю вам, как это сделать с его помощью. Но если вы все еще не используете библиотеку в своем проекте, я бы предложил вам использовать Intersection Observer API, так как Framer Motion довольно тяжелый. 

Framer Motion предоставляет перехватчик useInView, который возвращает логическое значение, указывающее, находится ли элемент в области просмотра или нет. Затем мы можем использовать это значение для запуска анимации.

App.js
"use client";

import { useInView } from "framer-motion";
import { useRef, useState } from "react";

export default function ImageRevealInner() {
  const ref = useRef(null);
  // Change to true only once & when at least 100px of the image is in view
  const isInView = useInView(ref, { once: true, margin: "-100px" });

  if (isInView && ref.current) {
    ref.current.animate(
      [{ clipPath: "inset(0 0 100% 0)" }, { clipPath: "inset(0 0 0 0)" }],
      {
        duration: 1000,
        fill: "forwards",
        easing: "cubic-bezier(0.77, 0, 0.175, 1)",
      },
    );
  }

  return (
    <>
	  <h1>scroll down</h1>
      <img
        className="image-reveal"
        alt="A series of diagonal black and white stripes with a smooth gradient effect. The alternating light and dark bands create a sense of depth and movement, resembling light rays or shadows cast across a surface. The overall aesthetic is abstract and high-contrast, with a sleek, modern feel."
        src="https://emilkowalski-git-the-magic-of-clip-path-emilkowalski-s-team.vercel.app/clip-path/raycast.jpg"
        height={430}
        ref={ref}
        width={644}
      />
    </>
  );
}
style.css
.image-reveal {
  margin-top: 150vh;
  clip-path: polygon(0 0, 100% 0, 100% 0, 0 0);
}

h1 {
  position: absolute;
  top: 50vh;
  left: 50%;
  transform: translateX(-50%);
  text-align: center;
  font-size: 16px;
}

Здесь я использовал WAAPI вместо анимации CSS, чтобы хранить всю логику, связанную с анимацией, в одном месте. Я также добавил в хук два параметра useInView. Этот once параметр определяет, что анимация включается только один раз, а также параметр margin гарантирует, что анимация включается, когда в поле зрения находится не менее 100 пикселей изображения.

Прокрутка прогресса

Еще одно взаимодействие с прокруткой, которую мы можем создать, clip-path — это вертикальная линия, которая становится длиннее по мере прокрутки вниз. Это может выглядеть как нарисованный SVG, но это лишь обрезанный элемент div, который мы постепенно раскрываем при прокрутке.

В этом случае я использовал хук useScroll из Framer Motion, чтобы получить информацию о прокрутке нашего контейнера. Параметр offset гарантирует, что мы начнем измерение, когда верхняя часть элемента получит просмотр нижней части, и закончим измерение части, когда нижняя часть элемента получит просмотр нижней части области. Таким образом, мы не возвращаем анимацию, когда пользователь прокручивает ее.

Благодаря useTransform мы можем сопоставить прогресс прокрутки (от 0 до 1) со значением, которое мы можем использовать в свойстве clip-path. Я в основном сопоставляю значения от 0 до 100% и от 1 до 0%, все, что находится между ними, рассчитывается автоматически.

const { scrollYProgress } = useScroll({
  target: containerRef,
  offset: ["start end", "end end"],
});
 
const clipPathY = useTransform(scrollYProgress, [0, 1], ["100%", "0%"]);

scrollYProgress это motion value, которое является противоположным значением Framer Motion. Оно содержит актуальное значение без повторной визуализации компонента, и это полезно, но это также ограничивает условия доступа к нему. 

Мне нужно использовать motion значение, чтобы создать новое motion значение, которое я могу использовать в clip-path свойстве. Это делается с помощью функции useMotionTemplate, которая позволяет нам создавать новое motion значение из строк шаблона, закладывая другое motion value.

const motionClipPath = useMotionTemplate`inset(0 0 ${clipPathY} 0)`;

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

<motion.div
  ref={containerRef}
  // This style value updates automatically,
  // because `motionClipPath` is a motion value
  style={{ clipPath: motionClipPath }}
>
  ...
</motion.div>

Это самый продвинутый пример в этой статье, поскольку он содержит некоторые более сложные функции Framer Motion. Я решил включить ее, чтобы показать вам, на что способна эта библиотека.

Переходные вкладки

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

Это нормально, но мы можем сделать лучше.

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

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

clip-path: inset(0 21% 0 52% round 17px);

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

App.js
import { useEffect, useRef, useState } from "react";

export default function TabsClipPath() {
  const [activeTab, setActiveTab] = useState(TABS[0].name);
  const containerRef = useRef(null);
  const activeTabElementRef = useRef(null);

  useEffect(() => {
    const container = containerRef.current;

    if (activeTab && container) {
      const activeTabElement = activeTabElementRef.current;

      if (activeTabElement) {
        const { offsetLeft, offsetWidth } = activeTabElement;

        const clipLeft = offsetLeft;
        const clipRight = offsetLeft + offsetWidth;
        container.style.clipPath = `inset(0 ${Number(100 - (clipRight / container.offsetWidth) * 100).toFixed()}% 0 ${Number((clipLeft / container.offsetWidth) * 100).toFixed()}% round 17px)`;
      }
    }
  }, [activeTab, activeTabElementRef, containerRef]);

  return (
    <div className="wrapper">
      <ul className="list">
        {TABS.map((tab) => (
          <li key={tab.name}>
            <button
              ref={activeTab === tab.name ? activeTabElementRef : null}
              data-tab={tab.name}
              onClick={() => {
                setActiveTab(tab.name);
              }}
              className="button"
            >
              {tab.icon}
              {tab.name}
            </button>
          </li>
        ))}
      </ul>

      <div aria-hidden className="clip-path-container" ref={containerRef}>
        <ul className="list list-overlay">
          {TABS.map((tab) => (
            <li key={tab.name}>
              <button
                data-tab={tab.name}
                onClick={() => {
                  setActiveTab(tab.name);
                }}
                className="button-overlay button"
				tabIndex={-1}
              >
                {tab.icon}
                {tab.name}
              </button>
            </li>
          ))}
        </ul>
      </div>
    </div>
  );
}

const TABS = [
  {
    name: "Payments",
    icon: (
      <svg
        aria-hidden="true"
        width="16"
        height="16"
        viewBox="0 0 16 16"
        fill="none"
        xmlns="http://www.w3.org/2000/svg"
      >
        <path
          fillRule="evenodd"
          clipRule="evenodd"
          fill="currentColor"
          d="M0 3.884c0-.8.545-1.476 1.306-1.68l.018-.004L10.552.213c.15-.038.3-.055.448-.055.927.006 1.75.733 1.75 1.74V4.5h.75A2.5 2.5 0 0 1 16 7v6.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 13.5V3.884ZM10.913 1.67c.199-.052.337.09.337.23v2.6H2.5c-.356 0-.694.074-1 .208v-.824c0-.092.059-.189.181-.227l9.216-1.984.016-.004ZM1.5 7v6.5a1 1 0 0 0 1 1h11a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1h-11a1 1 0 0 0-1 1Z"
        ></path>
        <path
          fillRule="evenodd"
          clipRule="evenodd"
          fill="currentColor"
          d="M10.897 1.673 1.681 3.657c-.122.038-.181.135-.181.227v.824a2.492 2.492 0 0 1 1-.208h8.75V1.898c0-.14-.138-.281-.337-.23m0 0-.016.005Zm-9.59.532 9.23-1.987c.15-.038.3-.055.448-.055.927.006 1.75.733 1.75 1.74V4.5h.75A2.5 2.5 0 0 1 16 7v6.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 13.5V3.884c0-.8.545-1.476 1.306-1.68l.018-.004ZM1.5 13.5V7a1 1 0 0 1 1-1h11a1 1 0 0 1 1 1v6.5a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1ZM13 10.25c0 .688-.563 1.25-1.25 1.25-.688 0-1.25-.55-1.25-1.25 0-.688.563-1.25 1.25-1.25.688 0 1.25.562 1.25 1.25Z"
        ></path>
      </svg>
    ),
  },
  {
    name: "Balances",
    icon: (
      <svg
        data-testid="primary-nav-item-icon"
        aria-hidden="true"
        width="16"
        height="16"
        viewBox="0 0 16 16"
        fill="none"
        xmlns="http://www.w3.org/2000/svg"
      >
        <path
          fill="currentColor"
          d="M1 2a.75.75 0 0 1 .75-.75h7.5a.75.75 0 0 1 0 1.5h-7.5A.75.75 0 0 1 1 2Zm0 8a.75.75 0 0 1 .75-.75h5a.75.75 0 0 1 0 1.5h-5A.75.75 0 0 1 1 10Zm2.25-4.75a.75.75 0 0 0 0 1.5h7.5a.75.75 0 0 0 0-1.5h-7.5ZM2.5 14a.75.75 0 0 1 .75-.75h4a.75.75 0 0 1 0 1.5h-4A.75.75 0 0 1 2.5 14Z"
        ></path>
        <path
          fillRule="evenodd"
          clipRule="evenodd"
          fill="currentColor"
          d="M16 11.5a3.5 3.5 0 1 1-7 0 3.5 3.5 0 0 1 7 0Zm-1.5 0a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z"
        ></path>
      </svg>
    ),
  },
  {
    name: "Customers",
    icon: (
      <svg
        aria-hidden="true"
        width="16"
        height="16"
        viewBox="0 0 16 16"
        fill="none"
        xmlns="http://www.w3.org/2000/svg"
      >
        <path
          fillRule="evenodd"
          clipRule="evenodd"
          fill="currentColor"
          d="M2.5 14.4h11a.4.4 0 0 0 .4-.4 3.4 3.4 0 0 0-3.4-3.4h-5A3.4 3.4 0 0 0 2.1 14c0 .22.18.4.4.4Zm0 1.6h11a2 2 0 0 0 2-2 5 5 0 0 0-5-5h-5a5 5 0 0 0-5 5 2 2 0 0 0 2 2ZM8 6.4a2.4 2.4 0 1 0 0-4.8 2.4 2.4 0 0 0 0 4.8ZM8 8a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z"
        ></path>
      </svg>
    ),
  },
  {
    name: "Billing",
    icon: (
      <svg
        aria-hidden="true"
        width="16"
        height="16"
        viewBox="0 0 16 16"
        fill="none"
        xmlns="http://www.w3.org/2000/svg"
      >
        <path
          fill="currentColor"
          d="M0 2.25A2.25 2.25 0 0 1 2.25 0h7.5A2.25 2.25 0 0 1 12 2.25v6a.75.75 0 0 1-1.5 0v-6a.75.75 0 0 0-.75-.75h-7.5a.75.75 0 0 0-.75.75v10.851a.192.192 0 0 0 .277.172l.888-.444a.75.75 0 1 1 .67 1.342l-.887.443A1.69 1.69 0 0 1 0 13.101V2.25Z"
        ></path>
        <path
          fill="currentColor"
          d="M5 10.7a.7.7 0 0 1 .7-.7h4.6a.7.7 0 1 1 0 1.4H7.36l.136.237c.098.17.193.336.284.491.283.483.554.907.855 1.263.572.675 1.249 1.109 2.365 1.109 1.18 0 2.038-.423 2.604-1.039.576-.626.896-1.5.896-2.461 0-.99-.42-1.567-.807-1.998a.75.75 0 1 1 1.115-1.004C15.319 8.568 16 9.49 16 11c0 1.288-.43 2.54-1.292 3.476C13.838 15.423 12.57 16 11 16c-1.634 0-2.706-.691-3.51-1.64-.386-.457-.71-.971-1.004-1.472L6.4 12.74v2.56a.7.7 0 1 1-1.4 0v-4.6ZM2.95 4.25a.75.75 0 0 1 .75-.75h2a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1-.75-.75ZM3.7 6.5a.75.75 0 0 0 0 1.5h4.6a.75.75 0 0 0 0-1.5H3.7Z"
        ></path>
      </svg>
    ),
  },
];
style.css
body {
   padding: 0 16px;
 }

 .wrapper {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  width: fit-content;
  margin: 0 auto;
  padding: 64px 0 0;
}

.list {
  position: relative;
  display: flex;
  width: 100%;
  justify-content: center;
  gap: 8px;
}

.list-overlay {
  background: #2090FF;
}

.button {
  display: flex;
  height: 34px;
  align-items: center;
  gap: 8px;
  border-radius: 100%;
  padding: 16px;
  font-size: 14px;
  font-weight: 500;
  color: #000;
  text-decoration: none;
}

.button.button-overlay {
  color: #fff;
}

.clip-path-container {
  position: absolute;
  z-index: 10;
  width: 100%;
  overflow: hidden;
  transition: clip-path 0.25s ease;
  clip-path: inset(0px 75% 0px 0% round 17px);
}

Верхушка айсберга

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

Источник:

#JavaScript #CSS
Комментарии
Чтобы оставить комментарий, необходимо авторизоваться

Присоединяйся в тусовку

В этом месте могла бы быть ваша реклама

Разместить рекламу