From our sponsor: Ready to show your plugin skills? Enter the Penpot Plugins Contest (Nov 15-Dec 15) to win cash prizes!
Custom cursors certainly were a big trend in web development in 2018. In the following tutorial we’ll take a look at how to create a magnetic noisy circle cursor for navigation elements as shown in Demo 4. We’ll be using Paper.js with Simplex Noise.
The Cursor Markup
The markup for the cursor will be split up into two elements. A simple <div> for the small white dot and a <Canvas> element to draw the red noisy circle using Paper.js.
<body class="tutorial">
<main class="page">
<div class="page__inner">
<!-- The cursor elements -->
<div class="cursor cursor--small"></div>
<canvas class="cursor cursor--canvas" resize></canvas>
</div>
</main>
</body>
Basic Colors and Layout
To give our demo some color and layout we’re defining some basic styles.
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%;
}
The Basic Cursor Styles
Basically both cursor elements have a fixed position. To be exactly at the tip of the mouse pointer, we adjust left and top of the small cursor. The canvas will simply fill the whole viewport.
.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;
}
The Link Element(s)
For the sake of simplicity we will just take one link element which contains an SVG icon that we can then animate on hover.
<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>
<!-- you can add more links here -->
</nav>
The Navigation and Link Styles
Here we’re defining some styles for the navigation, its items and hover transitions.
.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);
}
Including Paper and SimplexNoise
As mentioned before, we need to include Paper.js. To animate the noisy circle we need Simplex Noise in addition to that.
<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>
Hiding the System Cursor
Because we’re building our own cursor, we need to make sure to not show the system’s cursor in its normal state and when hovering links.
.page, .page a {
cursor: none;
}
Animating the Small Dot Cursor
In order to have smooth performance we use a requestAnimationFrame()-loop.
// set the starting position of the cursor outside of the screen
let clientX = -100;
let clientY = -100;
const innerCursor = document.querySelector(".cursor--small");
const initCursor = () => {
// add listener to track the current mouse position
document.addEventListener("mousemove", e => {
clientX = e.clientX;
clientY = e.clientY;
});
// transform the innerCursor to the current mouse position
// use requestAnimationFrame() for smooth performance
const render = () => {
innerCursor.style.transform = `translate(${clientX}px, ${clientY}px)`;
// if you are already using TweenMax in your project, you might as well
// use TweenMax.set() instead
// TweenMax.set(innerCursor, {
// x: clientX,
// y: clientY
// });
requestAnimationFrame(render);
};
requestAnimationFrame(render);
};
initCursor();
Setting up the Circle on Canvas
The following is the basis for the red circle part of the cursor. In order to move the red circle around we’ll use a technique called linear interpolation.
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;
// we'll need these later for the noisy circle
const noiseScale = 150; // speed
const noiseRange = 4; // range of distortion
let isNoisy = false; // state
// the base shape for the noisy circle
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 = [];
// function for linear interpolation of values
const lerp = (a, b, n) => {
return (1 - n) * a + n * b;
};
// function to map a value from one range to another range
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
);
};
// the draw loop of Paper.js
// (60fps with requestAnimationFrame under the hood)
paper.view.onFrame = event => {
// using linear interpolation, the circle will move 0.2 (20%)
// of the distance between its current position and the mouse
// coordinates per Frame
lastX = lerp(lastX, clientX, 0.2);
lastY = lerp(lastY, clientY, 0.2);
group.position = new paper.Point(lastX, lastY);
}
}
initCanvas();
Handling the Hover State
const initHovers = () => {
// find the center of the link element and set stuckX and stuckY
// these are needed to set the position of the noisy circle
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;
};
// reset isStuck on mouseLeave
const handleMouseLeave = () => {
isStuck = false;
};
// add event listeners to all items
const linkItems = document.querySelectorAll(".link");
linkItems.forEach(item => {
item.addEventListener("mouseenter", handleMouseEnter);
item.addEventListener("mouseleave", handleMouseLeave);
});
};
initHovers();
Making the Circle “Magnetic” and “Noisy”
The following snipped is the extended version of the above-mentioned paper.view.onFrame method.
// the draw loop of Paper.js
// (60fps with requestAnimationFrame under the hood)
paper.view.onFrame = event => {
// using linear interpolation, the circle will move 0.2 (20%)
// of the distance between its current position and the mouse
// coordinates per Frame
if (!isStuck) {
// move circle around normally
lastX = lerp(lastX, clientX, 0.2);
lastY = lerp(lastY, clientY, 0.2);
group.position = new paper.Point(lastX, lastY);
} else if (isStuck) {
// fixed position on a nav item
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) {
// scale up the shape
polygon.scale(1.08);
} else if (!isStuck && polygon.bounds.width > 30) {
// remove noise
if (isNoisy) {
polygon.segments.forEach((segment, i) => {
segment.point.set(bigCoordinates[i][0], bigCoordinates[i][1]);
});
isNoisy = false;
bigCoordinates = [];
}
// scale down the shape
const scaleDown = 0.92;
polygon.scale(scaleDown);
}
// while stuck and big, apply simplex noise
if (isStuck && polygon.bounds.width >= shapeBounds.width) {
isNoisy = true;
// first get coordinates of large circle
if (bigCoordinates.length === 0) {
polygon.segments.forEach((segment, i) => {
bigCoordinates[i] = [segment.point.x, segment.point.y];
});
}
// loop over all points of the polygon
polygon.segments.forEach((segment, i) => {
// get new noise value
// we divide event.count by noiseScale to get a very smooth value
const noiseX = noiseObjects[i].noise2D(event.count / noiseScale, 0);
const noiseY = noiseObjects[i].noise2D(event.count / noiseScale, 1);
// map the noise value to our defined range
const distortionX = map(noiseX, -1, 1, -noiseRange, noiseRange);
const distortionY = map(noiseY, -1, 1, -noiseRange, noiseRange);
// apply distortion to coordinates
const newX = bigCoordinates[i][0] + distortionX;
const newY = bigCoordinates[i][1] + distortionY;
// set new (noisy) coodrindate of point
segment.point.set(newX, newY);
});
}
polygon.smooth();
};
General Remarks
I hope you enjoyed this tutorial and have fun playing around with it in your own projects. Of course this is just a starting point and you can go crazier with animations, shapes, colors etc. If you have any questions, please feel free to reach out or shoot me a tweet!
References and Credits
- Images from Unsplash.com
- Paper.js by Jürg Lehni & Jonathan Puckey
- Simplex Noise by Jonas Wagner
- PhotoSwipe by Dmitry Semenov
- Swiper by iDangero.us
- TweenMax by Greensock
- Lazysizes by Alexander Farkas