import { gsap } from 'gsap'; /** * Linear interpolation * @param {Number} a - first value to interpolate * @param {Number} b - second value to interpolate * @param {Number} n - amount to interpolate */ const lerp = (a, b, n) => (1 - n) * a + n * b; /** * Gets the cursor position * @param {Event} ev - mousemove event */ const getCursorPos = ev => { return { x : ev.clientX, y : ev.clientY }; }; // Track the cursor position let cursor = {x: 0, y: 0}; window.addEventListener('mousemove', ev => cursor = getCursorPos(ev)); /** * Class representing a custom cursor. * A Cursor can have multiple elements/svgs */ export class Cursor { // DOM elements DOM = { // cursor elements (SVGs .cursor) elements: null, } // All CursorElement instances cursorElements = []; /** * Constructor. * @param {NodeList} Dom_elems - all .cursor elements * @param {String} triggerSelector - Trigger the cursor enter/leave method on the this selector returned elements. Default is all . */ constructor(Dom_elems, triggerSelector = 'a') { this.DOM.elements = Dom_elems; [...this.DOM.elements].forEach(el => this.cursorElements.push(new CursorElement(el))); [...document.querySelectorAll(triggerSelector)].forEach(link => { link.addEventListener('mouseenter', () => this.enter()); link.addEventListener('mouseleave', () => this.leave()); }); } /** * Mouseenter event */ enter() { for (const el of this.cursorElements) { el.enter(); } } /** * Mouseleave event */ leave() { for (const el of this.cursorElements) { el.leave(); } } } /** * Class representing a .cursor element */ class CursorElement { // DOM elements DOM = { // Main element (.cursor) el: null, // Inner element (.cursor__inner) inner: null, // feTurbulence element feTurbulence: null } // Scales value when entering an element radiusOnEnter = 30; // Opacity value when entering an element opacityOnEnter = 1; // radius radius; // Element style properties renderedStyles = { // With interpolation, we can achieve a smooth animation effect when moving the cursor. // The "previous" and "current" values are the values that will interpolate. // The returned value will be one between these two (previous and current) at a specific increment. // The "amt" is the amount to interpolate. // As an example, the following formula calculates the x-axis translation value to apply to the cursor element: // this.renderedStyles.tx.previous = lerp(this.renderedStyles.tx.previous, this.renderedStyles.tx.current, this.renderedStyles.tx.amt); // translation, scale and opacity values // The lower the amt, the slower the cursor "follows" the user gesture tx: {previous: 0, current: 0, amt: 0.2}, ty: {previous: 0, current: 0, amt: 0.2}, // The scale and opacity will change when hovering over any element specified in [triggerSelector] // Defaults are 1 for both properties //scale: {previous: 1, current: 1, amt: 0.2}, radius: {previous: 20, current: 20, amt: 0.2}, opacity: {previous: 1, current: 1, amt: 0.2} }; // Element size and position bounds; // SVG filter id filterId = '#cursor-filter'; // for the filter animation primitiveValues = {turbulence: 0}; /** * Constructor. */ constructor(DOM_el) { this.DOM.el = DOM_el; this.DOM.inner = this.DOM.el.querySelector('.cursor__inner'); this.DOM.feTurbulence = document.querySelector(`${this.filterId} > feTurbulence`); this.createFilterTimeline(); // Hide it initially this.DOM.el.style.opacity = 0; // Calculate size and position this.bounds = this.DOM.el.getBoundingClientRect(); // Check if any options passed in data attributes this.radiusOnEnter = this.DOM.el.dataset.radiusEnter || this.radiusOnEnter; this.opacityOnEnter = this.DOM.el.dataset.opacityEnter || this.opacityOnEnter; for (const key in this.renderedStyles) { this.renderedStyles[key].amt = this.DOM.el.dataset.amt || this.renderedStyles[key].amt; } this.radius = this.DOM.inner.getAttribute('r'); this.renderedStyles['radius'].previous = this.renderedStyles['radius'].current = this.radius; // Show the element and start tracking its position as soon as the user moves the cursor. const onMouseMoveEv = () => { // Set up the initial values to be the same this.renderedStyles.tx.previous = this.renderedStyles.tx.current = cursor.x - this.bounds.width/2; this.renderedStyles.ty.previous = this.renderedStyles.ty.previous = cursor.y - this.bounds.height/2; // Show it this.DOM.el.style.opacity = 1; // Start rAF loop requestAnimationFrame(() => this.render()); // Remove the initial mousemove event window.removeEventListener('mousemove', onMouseMoveEv); }; window.addEventListener('mousemove', onMouseMoveEv); } /** * Mouseenter event * Scale up and fade out. */ enter() { this.renderedStyles['radius'].current = this.radiusOnEnter; this.renderedStyles['opacity'].current = this.opacityOnEnter; this.filterTimeline.restart(); } /** * Mouseleave event * Reset scale and opacity. */ leave() { this.renderedStyles['radius'].current = this.radius; this.renderedStyles['opacity'].current = 1; this.filterTimeline.progress(1).kill(); } createFilterTimeline() { this.filterTimeline = gsap.timeline({ paused: true, onStart: () => { this.DOM.inner.style.filter = `url(${this.filterId}`; }, onUpdate: () => { this.DOM.feTurbulence.setAttribute('baseFrequency', this.primitiveValues.turbulence); }, onComplete: () => { this.DOM.inner.style.filter = 'none'; } }) .to(this.primitiveValues, { duration: 3, ease: 'none', repeat: -1, yoyo: true, startAt: {turbulence: 0.15}, turbulence: 0.13 }); } /** * Loop / Interpolation */ render() { // New cursor positions this.renderedStyles['tx'].current = cursor.x - this.bounds.width/2; this.renderedStyles['ty'].current = cursor.y - this.bounds.height/2; // Interpolation for (const key in this.renderedStyles ) { this.renderedStyles[key].previous = lerp(this.renderedStyles[key].previous, this.renderedStyles[key].current, this.renderedStyles[key].amt); } // Apply interpolated values (smooth effect) this.DOM.el.style.transform = `translateX(${(this.renderedStyles['tx'].previous)}px) translateY(${this.renderedStyles['ty'].previous}px)`; this.DOM.inner.setAttribute('r', this.renderedStyles['radius'].previous); this.DOM.el.style.opacity = this.renderedStyles['opacity'].previous; // loop... requestAnimationFrame(() => this.render()); } }