Пользовательские эффекты курсора

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

Посмотреть демо Скачать исходный код

В данной статье мы рассмотрим, как создать курсор с эффектом преобразования в деформированный круг для элементов навигации. Мы будем использовать Paper.js с Simplex Noise.

Пользовательский эффект курсора, который мы собираемся создать

Разметка

Разметка для курсора состоит из <div> для маленькой белой точки и элемента <Canvas> для рисования красного круга с помощью Paper.js.

<body class="tutorial">
  <main class="page">
    <div class="page__inner">
      
      <!-- Элемент курсора --> 
      <div class="cursor cursor--small"></div>
      <canvas class="cursor cursor--canvas" resize></canvas>
      
    </div>
  </main>
</body>

Основные цвета и макет

Определяем основные стили.

body.tutorial {
  --color-text: #fff;
  --color-bg: #171717;
  --color-link: #ff0000;
  background-color: var(--color-bg);
}
.page {
  position: absolute;
  width: 100%;
  height: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
}
.page__inner {
  display: flex;
  justify-content: center;
  width: 100%;
}

Стили курсора

Оба элемента курсора имеют фиксированную позицию. На кончике указателя мыши мы настраиваем левую и верхнюю часть маленького курсора. Холст просто заполнит все окно просмотра.

.cursor {
  position: fixed;
  left: 0;
  top: 0;
  pointer-events: none;
}
.cursor--small {
  width: 5px;
  height: 5px;
  left: -2.5px;
  top: -2.5px;
  border-radius: 50%;
  z-index: 11000;
  background: var(--color-text);
}
.cursor--canvas {
  width: 100vw;
  height: 100vh;
  z-index: 12000;
}

Ссылки

Для простоты используем один элемент ссылки, содержащий SVG-иконку, которую затем можно анимировать при наведении курсора.

<nav class="nav">
 <a href="#" class="link">
  <svg class="settings-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
   <g class="settings-icon__group settings-icon__group--1">
     <line class="settings-icon__line" x1="79.69" y1="16.2" x2="79.69" y2="83.8"/>
     <rect class="settings-icon__rect" x="73.59" y="31.88" width="12.19" height="12.19"/>
    </g>
   <g class="settings-icon__group settings-icon__group--2">
     <line class="settings-icon__line" x1="50.41" y1="16.2" x2="50.41" y2="83.8"/>
     <rect class="settings-icon__rect" x="44.31" y="54.33" width="12.19" height="12.19"/>
   </g>
   <g class="settings-icon__group settings-icon__group--3">
     <line class="settings-icon__line" x1="20.31" y1="16.2" x2="20.31" y2="83.8"/>
     <rect class="settings-icon__rect" x="14.22" y="26.97" width="12.19" height="12.19"/>
   </g>
  </svg>
 </a>
 <!-- здесь вы можете указать другие ссылки -->
</nav>

Стили меню навигации и ссылок

Определим стили для меню навигации, его элементов и переходов при наведении.

.nav {
  display: flex;
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);  
}
.link {
  display: flex;
  width: 75px;
  height: 75px;
  margin: 0 5px;
  justify-content: center;
  align-items: center;
}
.settings-icon {
  display: block;
  width: 40px;
  height: 40px;
}
.settings-icon__line {
  stroke: var(--color-text);
  stroke-width: 5px;
  transition: all 0.2s ease 0.05s;
}
.settings-icon__rect {
  stroke: var(--color-text);
  fill: var(--color-bg);
  stroke-width: 5px;
  transition: all 0.2s ease 0.05s;
}
.link:hover .settings-icon__line,
.link:hover .settings-icon__rect {
  stroke: var(--color-link);
  transition: all 0.2s ease 0.05s;
}
.link:hover .settings-icon__group--1 .settings-icon__rect {
  transform: translateY(20px);
}
.link:hover .settings-icon__group--2 .settings-icon__rect {
  transform: translateY(-20px);
}
.link:hover .settings-icon__group--3 .settings-icon__rect {
  transform: translateY(25px);
} 
Стили меню навигации и ссылок

Так должен выглядеть полученный результат.

Включение Paper и  SimplexNoise

Нам нужно подключить Paper.js и Simplex Noise .

<script src="https://cdnjs.cloudflare.com/ajax/libs/paper.js/0.12.0/paper-core.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/simplex-noise/2.4.0/simplex-noise.min.js"></script>

Скрытие системного курсора

Мы создаем собственный курсор. Поэтому нужно, чтобы системный курсор не отображался.

.page, .page a {
 cursor: none;
}

Анимация маленького точечного курсора

Чтобы обеспечить плавную работу, мы используем цикл requestAnimationFrame().

// задаем начальную позицию курсора за пределами экрана
let clientX = -100;
let clientY = -100;
const innerCursor = document.querySelector(".cursor--small");

const initCursor = () => {
  // добавляем прослушиватель для отслеживания текущей позиции мыши
  document.addEventListener("mousemove", e => {
    clientX = e.clientX;
    clientY = e.clientY;
  });
  
  // преобразуем innerCursor в текущую позицию мыши
  // используем requestAnimationFrame() для плавной работы
  const render = () => {
    innerCursor.style.transform = `translate(${clientX}px, ${clientY}px)`;
    // если вы уже используете TweenMax в проекте, то можете также
    // использовать TweenMax.set()
    // TweenMax.set(innerCursor, {
    //   x: clientX,
    //   y: clientY
    // });
    
    requestAnimationFrame(render);
  };
  requestAnimationFrame(render);
};

initCursor();

Настройка круга на холсте

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

let lastX = 0;
let lastY = 0;
let isStuck = false;
let showCursor = false;
let group, stuckX, stuckY, fillOuterCursor;

const initCanvas = () => {
  const canvas = document.querySelector(".cursor--canvas");
  const shapeBounds = {
    width: 75,
    height: 75
  };
  paper.setup(canvas);
  const strokeColor = "rgba(255, 0, 0, 0.5)";
  const strokeWidth = 1;
  const segments = 8;
  const radius = 15;
  
  // это понадобится нам позже для деформированного круга
  const noiseScale = 150; // скорость
  const noiseRange = 4; // диапазон деформации
  let isNoisy = false; // состояние
  
  // базовая фигура для деформированного круга
  const polygon = new paper.Path.RegularPolygon(
    new paper.Point(0, 0),
    segments,
    radius
  );
  polygon.strokeColor = strokeColor;
  polygon.strokeWidth = strokeWidth;
  polygon.smooth();
  group = new paper.Group([polygon]);
  group.applyMatrix = false;
  
  const noiseObjects = polygon.segments.map(() => new SimplexNoise());
  let bigCoordinates = [];
  
  // функция для линейной интерполяции значений
  const lerp = (a, b, n) => {
    return (1 - n) * a + n * b;
  };
  
  // функция для сопоставления значений из одного диапазона с другим
  const map = (value, in_min, in_max, out_min, out_max) => {
    return (
      ((value - in_min) * (out_max - out_min)) / (in_max - in_min) + out_min
    );
  };
  
  // цикл рисования Paper.js 
  // (60fps с помощью requestAnimationFrame)
  paper.view.onFrame = event => {
    // используем линейную интерполяцию, круг сместится на 0.2 (20%)
    // от расстояния между текущей позицией и координатами мыши для каждого Frame
    lastX = lerp(lastX, clientX, 0.2);
    lastY = lerp(lastY, clientY, 0.2);
    group.position = new paper.Point(lastX, lastY);
  }
}

initCanvas();
Настройка круга на холсте

Пользовательский курсор уже перемещается по экрану.

Обработка состояния наведения

const initHovers = () => {

  // находим центр элемента ссылки и устанавливаем stuckX и stuckY
  // это необходимо, чтобы задать позицию деформированного круга
  const handleMouseEnter = e => {
    const navItem = e.currentTarget;
    const navItemBox = navItem.getBoundingClientRect();
    stuckX = Math.round(navItemBox.left + navItemBox.width / 2);
    stuckY = Math.round(navItemBox.top + navItemBox.height / 2);
    isStuck = true;
  };
  
  // сбрасываем isStuck к mouseLeave
  const handleMouseLeave = () => {
    isStuck = false;
  };
  
  // добавляем ко всем элементам прослушиватели событий
  const linkItems = document.querySelectorAll(".link");
  linkItems.forEach(item => {
    item.addEventListener("mouseenter", handleMouseEnter);
    item.addEventListener("mouseleave", handleMouseLeave);
  });
};

initHovers();

Деформация круга курсора

Ниже приведен фрагмент расширенной версии упомянутого выше метода paper.view.onFrame.

// цикл рисования Paper.js
// (60fps с помощью requestAnimationFrame)
paper.view.onFrame = event => {
    // используем линейную интерполяцию, круг сместится на 0.2 (20%)
    // от расстояния между текущей позицией и координатами мыши для 
каждого Frame
  if (!isStuck) {
    // переворачиваем круг
    lastX = lerp(lastX, clientX, 0.2);
    lastY = lerp(lastY, clientY, 0.2);
    group.position = new paper.Point(lastX, lastY);
  } else if (isStuck) {
    // фиксированная позиция для элемента навигации
    lastX = lerp(lastX, stuckX, 0.2);
    lastY = lerp(lastY, stuckY, 0.2);
    group.position = new paper.Point(lastX, lastY);
  }
  
  if (isStuck && polygon.bounds.width < shapeBounds.width) { 
    // растягиваем фигуру 
    polygon.scale(1.08);
  } else if (!isStuck && polygon.bounds.width > 30) {
    // удаляем шум
    if (isNoisy) {
      polygon.segments.forEach((segment, i) => {
        segment.point.set(bigCoordinates[i][0], bigCoordinates[i][1]);
      });
      isNoisy = false;
      bigCoordinates = [];
    }
    // сжимаем фигуру
    const scaleDown = 0.92;
    polygon.scale(scaleDown);
  }
  
  добавляем простой шум
  if (isStuck && polygon.bounds.width >= shapeBounds.width) {
    isNoisy = true;
    // сначала получаем координаты большого круга
    if (bigCoordinates.length === 0) {
      polygon.segments.forEach((segment, i) => {
        bigCoordinates[i] = [segment.point.x, segment.point.y];
      });
    }
    
    // перебираем через цикл все точки многоугольника
    polygon.segments.forEach((segment, i) => {
      
      // получаем новое значение шума
      // делим event.count на noiseScale, чтобы получить сглаженное значение
      const noiseX = noiseObjects[i].noise2D(event.count / noiseScale, 0);
      const noiseY = noiseObjects[i].noise2D(event.count / noiseScale, 1);
      
      // сопоставляем значение шума с определенным диапазоном
      const distortionX = map(noiseX, -1, 1, -noiseRange, noiseRange);
      const distortionY = map(noiseY, -1, 1, -noiseRange, noiseRange);
      
      // применяем деформацию для координат
      const newX = bigCoordinates[i][0] + distortionX;
      const newY = bigCoordinates[i][1] + distortionY;
      
      // устанавливаем новые (деформированные) координаты
      segment.point.set(newX, newY);
    });
    
  }
  polygon.smooth();
};
Деформация круга курсора

Мы реализовали эффект деформированного круга.

Заключение

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