Пользовательские эффекты курсора
Учебное пособие по созданию анимированных пользовательских эффектов курсора для меню навигации, галереи и карусели.
Посмотреть демо Скачать исходный код
В данной статье мы рассмотрим, как создать курсор с эффектом преобразования в деформированный круг для элементов навигации. Мы будем использовать 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();
};

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