mouse-follower
Version:
A powerful javascript library to create amazing and smooth effects for the mouse cursor on your website.
585 lines (535 loc) • 20.4 kB
JavaScript
/*!
* Cuberto Mouse Follower
* https://cuberto.com/
*
* @version 1.1.2
* @author Cuberto, Artem Dordzhiev (Draft)
*/
export default class MouseFollower {
/**
* @typedef {Object} MouseFollowerOptions
* @property {string|HTMLElement|null} [el] Existed cursor element.
* @property {string|HTMLElement|null} [container] Cursor container.
* @property {string} [className] Cursor root element class name.
* @property {string} [innerClassName] Inner element class name.
* @property {string} [textClassName] Text element class name.
* @property {string} [mediaClassName] Media element class name.
* @property {string} [mediaBoxClassName] Media inner element class name.
* @property {string} [iconSvgClassName] SVG sprite class name.
* @property {string} [iconSvgNamePrefix] SVG sprite icon class name prefix.
* @property {string} [iconSvgSrc] SVG sprite source.
* @property {string|null} [dataAttr] Name of data attribute for changing cursor state directly in HTML.
* @property {string} [hiddenState] Hidden state name.
* @property {string} [textState] Text state name.
* @property {string} [iconState] Icon state name.
* @property {string|null} [activeState] Active (mousedown) state name. Set false to disable.
* @property {string} [mediaState] Media (image/video) state name.
* @property {Object} [stateDetection] State detection rules.
* @property {boolean} [visible] Is cursor visible by default.
* @property {boolean} [visibleOnState] Automatically show/hide cursor when state added.
* @property {number} [speed] Cursor movement speed.
* @property {string} [ease] Timing function of cursor movement.
* @property {boolean} [overwrite] Overwrite or remain cursor position when `mousemove` event happens.
* @property {number} [skewing] Default skewing factor.
* @property {number} [skewingText] Skewing effect factor in a text state.
* @property {number} [skewingIcon] Skewing effect factor in a icon state.
* @property {number} [skewingMedia] Skewing effect factor in a media (image/video) state.
* @property {number} [skewingDelta] Skewing effect base delta.
* @property {number} [skewingDeltaMax] Skew effect max delta.
* @property {number} [stickDelta] Stick effect delta.
* @property {number} [showTimeout] Delay before show.
* @property {boolean} [hideOnLeave] Hide the cursor when mouse leave container.
* @property {number} [hideTimeout] Delay before hiding. It should be equal to the CSS hide animation time.
* @property {number[]} [initialPos] Array (x, y) of initial cursor position.
*/
/**
* Register GSAP animation library.
*
* @param {gsap} gsap GSAP library.
*/
static registerGSAP(gsap) {
MouseFollower.gsap = gsap;
}
/**
* Create cursor instance.
*
* @param {MouseFollowerOptions} [options] Cursor options.
*/
constructor(options = {}) {
/** @type {MouseFollowerOptions} **/
this.options = Object.assign({}, {
el: null,
container: document.body,
className: 'mf-cursor',
innerClassName: 'mf-cursor-inner',
textClassName: 'mf-cursor-text',
mediaClassName: 'mf-cursor-media',
mediaBoxClassName: 'mf-cursor-media-box',
iconSvgClassName: 'mf-svgsprite',
iconSvgNamePrefix: '-',
iconSvgSrc: '',
dataAttr: 'cursor',
hiddenState: '-hidden',
textState: '-text',
iconState: '-icon',
activeState: '-active',
mediaState: '-media',
stateDetection: {
'-pointer': 'a,button',
},
visible: true,
visibleOnState: false,
speed: 0.55,
ease: 'expo.out',
overwrite: true,
skewing: 0,
skewingText: 2,
skewingIcon: 2,
skewingMedia: 2,
skewingDelta: 0.001,
skewingDeltaMax: 0.15,
stickDelta: 0.15,
showTimeout: 0,
hideOnLeave: true,
hideTimeout: 300,
hideMediaTimeout: 300,
initialPos: [-window.innerWidth, -window.innerHeight],
}, options);
if (this.options.visible && options.stateDetection == null) this.options.stateDetection['-hidden'] = 'iframe';
this.gsap = MouseFollower.gsap || window.gsap;
this.el = typeof (this.options.el) === 'string' ?
document.querySelector(this.options.el) : this.options.el;
this.container = typeof (this.options.container) === 'string' ?
document.querySelector(this.options.container) : this.options.container;
this.skewing = this.options.skewing;
this.pos = {x: this.options.initialPos[0], y: this.options.initialPos[1]};
this.vel = {x: 0, y: 0};
this.event = {};
this.events = [];
this.init();
}
/**
* Init cursor.
*/
init() {
if (!this.el) this.create();
this.createSetter();
this.bind();
this.render(true);
this.ticker = this.render.bind(this, false);
this.gsap.ticker.add(this.ticker);
}
/**
* Create cursor DOM element and append to container.
*/
create() {
this.el = document.createElement('div');
this.el.className = this.options.className;
this.el.classList.add(this.options.hiddenState);
this.inner = document.createElement('div');
this.inner.className = this.options.innerClassName;
this.text = document.createElement('div');
this.text.className = this.options.textClassName;
this.media = document.createElement('div');
this.media.className = this.options.mediaClassName;
this.mediaBox = document.createElement('div');
this.mediaBox.className = this.options.mediaBoxClassName;
this.media.appendChild(this.mediaBox);
this.inner.appendChild(this.media);
this.inner.appendChild(this.text);
this.el.appendChild(this.inner);
this.container.appendChild(this.el);
}
/**
* Create GSAP setters.
*/
createSetter() {
this.setter = {
x: this.gsap.quickSetter(this.el, 'x', 'px'),
y: this.gsap.quickSetter(this.el, 'y', 'px'),
rotation: this.gsap.quickSetter(this.el, 'rotation', 'deg'),
scaleX: this.gsap.quickSetter(this.el, 'scaleX'),
scaleY: this.gsap.quickSetter(this.el, 'scaleY'),
wc: this.gsap.quickSetter(this.el, 'willChange'),
inner: {
rotation: this.gsap.quickSetter(this.inner, 'rotation', 'deg'),
},
};
}
/**
* Create and attach events.
*/
bind() {
this.event.mouseleave = () => this.hide();
this.event.mouseenter = () => this.show();
this.event.mousedown = () => this.addState(this.options.activeState);
this.event.mouseup = () => this.removeState(this.options.activeState);
this.event.mousemoveOnce = () => this.show();
this.event.mousemove = (e) => {
this.gsap.to(this.pos, {
x: this.stick ? this.stick.x - ((this.stick.x - e.clientX) * this.options.stickDelta) : e.clientX,
y: this.stick ? this.stick.y - ((this.stick.y - e.clientY) * this.options.stickDelta) : e.clientY,
overwrite: this.options.overwrite,
ease: this.options.ease,
duration: this.visible ? this.options.speed : 0,
onUpdate: () => this.vel = {x: e.clientX - this.pos.x, y: e.clientY - this.pos.y},
});
};
this.event.mouseover = (e) => {
for (let target = e.target; target && target !== this.container; target = target.parentNode) {
if (e.relatedTarget && target.contains(e.relatedTarget)) break;
for (let state in this.options.stateDetection) {
if (target.matches(this.options.stateDetection[state])) this.addState(state);
}
if (this.options.dataAttr) {
const params = this.getFromDataset(target);
if (params.state) this.addState(params.state);
if (params.text) this.setText(params.text);
if (params.icon) this.setIcon(params.icon);
if (params.img) this.setImg(params.img);
if (params.video) this.setVideo(params.video);
if (typeof (params.show) !== 'undefined') this.show();
if (typeof (params.stick) !== 'undefined') this.setStick(params.stick || target);
}
}
};
this.event.mouseout = (e) => {
for (let target = e.target; target && target !== this.container; target = target.parentNode) {
if (e.relatedTarget && target.contains(e.relatedTarget)) break;
for (let state in this.options.stateDetection) {
if (target.matches(this.options.stateDetection[state])) this.removeState(state);
}
if (this.options.dataAttr) {
const params = this.getFromDataset(target);
if (params.state) this.removeState(params.state);
if (params.text) this.removeText();
if (params.icon) this.removeIcon();
if (params.img) this.removeImg();
if (params.video) this.removeVideo();
if (typeof (params.show) !== 'undefined') this.hide();
if (typeof (params.stick) !== 'undefined') this.removeStick();
}
}
};
if (this.options.hideOnLeave) {
this.container.addEventListener('mouseleave', this.event.mouseleave, {passive: true});
}
if (this.options.visible) {
this.container.addEventListener('mouseenter', this.event.mouseenter, {passive: true});
}
if (this.options.activeState) {
this.container.addEventListener('mousedown', this.event.mousedown, {passive: true});
this.container.addEventListener('mouseup', this.event.mouseup, {passive: true});
}
this.container.addEventListener('mousemove', this.event.mousemove, {passive: true});
if (this.options.visible) {
this.container.addEventListener('mousemove', this.event.mousemoveOnce, {
passive: true,
once: true,
});
}
if (this.options.stateDetection || this.options.dataAttr) {
this.container.addEventListener('mouseover', this.event.mouseover, {passive: true});
this.container.addEventListener('mouseout', this.event.mouseout, {passive: true});
}
}
/**
* Render the cursor in a new position.
*
* @param {boolean} [force=false] Force render.
*/
render(force) {
if (force !== true && (this.vel.y === 0 || this.vel.x === 0)) {
this.setter.wc('auto');
return;
}
this.trigger('render');
this.setter.wc('transform');
this.setter.x(this.pos.x);
this.setter.y(this.pos.y);
if (this.skewing) {
const distance = Math.sqrt(Math.pow(this.vel.x, 2) + Math.pow(this.vel.y, 2));
const scale = Math.min(distance * this.options.skewingDelta,
this.options.skewingDeltaMax) * this.skewing;
const angle = Math.atan2(this.vel.y, this.vel.x) * 180 / Math.PI;
this.setter.rotation(angle);
this.setter.scaleX(1 + scale);
this.setter.scaleY(1 - scale);
this.setter.inner.rotation(-angle);
}
}
/**
* Show cursor.
*/
show() {
this.trigger('show');
clearInterval(this.visibleInt);
this.visibleInt = setTimeout(() => {
this.el.classList.remove(this.options.hiddenState);
this.visible = true;
this.render(true);
}, this.options.showTimeout);
}
/**
* Hide cursor.
*/
hide() {
this.trigger('hide');
clearInterval(this.visibleInt);
this.el.classList.add(this.options.hiddenState);
this.visibleInt = setTimeout(() => this.visible = false, this.options.hideTimeout);
}
/**
* Toggle cursor.
*
* @param {boolean} [force] Force state.
*/
toggle(force) {
if (force === true || force !== false && !this.visible) {
this.show();
} else {
this.hide();
}
}
/**
* Add state/states to the cursor.
*
* @param {string} state State name.
*/
addState(state) {
this.trigger('addState', state);
if (state === this.options.hiddenState) return this.hide();
this.el.classList.add(...state.split(' '));
if (this.options.visibleOnState) this.show();
}
/**
* Remove state/states from cursor.
*
* @param {string} state State name.
*/
removeState(state) {
this.trigger('removeState', state);
if (state === this.options.hiddenState) return this.show();
this.el.classList.remove(...state.split(' '));
if (this.options.visibleOnState && this.el.className === this.options.className) this.hide();
}
/**
* Toggle cursor state.
*
* @param {string} state State name.
* @param {boolean} [force] Force state.
*/
toggleState(state, force) {
if (force === true || force !== false && !this.el.classList.contains(state)) {
this.addState(state);
} else {
this.removeState(state);
}
}
/**
* Set factor of skewing effect.
*
* @param {number} value Skewing factor.
*/
setSkewing(value) {
this.gsap.to(this, {skewing: value});
}
/**
* Reverts skewing factor to default.
*/
removeSkewing() {
this.gsap.to(this, {skewing: this.options.skewing});
}
/**
* Stick cursor to the element.
*
* @param {string|HTMLElement} element Element or selector.
*/
setStick(element) {
const el = typeof (element) === 'string' ? document.querySelector(element) : element;
const rect = el.getBoundingClientRect();
this.stick = {
y: rect.top + (rect.height / 2),
x: rect.left + (rect.width / 2),
};
}
/**
* Unstick cursor from the element.
*/
removeStick() {
this.stick = false;
}
/**
* Transform cursor to text mode with a given string.
*
* @param {string} text Text.
*/
setText(text) {
this.text.innerHTML = text;
this.addState(this.options.textState);
this.setSkewing(this.options.skewingText);
}
/**
* Reverts cursor from text mode.
*/
removeText() {
this.removeState(this.options.textState);
this.removeSkewing();
}
/**
* Transform cursor to svg icon mode.
*
* @param {string} name Icon identifier.
* @param {string} [style=""] Additional SVG styles.
*/
setIcon(name, style = '') {
this.text.innerHTML = `<svg class='${this.options.iconSvgClassName} ${this.options.iconSvgNamePrefix}${name}'`
+ ` style='${style}'><use xlink:href='${this.options.iconSvgSrc}#${name}'></use></svg>`;
this.addState(this.options.iconState);
this.setSkewing(this.options.skewingIcon);
}
/**
* Reverts cursor from icon mode.
*/
removeIcon() {
this.removeState(this.options.iconState);
this.removeSkewing();
}
/**
* Transform cursor to media mode with a given element.
*
* @param {HTMLElement} element Element.
*/
setMedia(element) {
clearTimeout(this.mediaInt);
if (element) {
this.mediaBox.innerHTML = '';
this.mediaBox.appendChild(element);
}
this.mediaInt = setTimeout(() => this.addState(this.options.mediaState), 20);
this.setSkewing(this.options.skewingMedia);
}
/**
* Revert cursor from media mode.
*/
removeMedia() {
clearTimeout(this.mediaInt);
this.removeState(this.options.mediaState);
this.mediaInt = setTimeout(() => this.mediaBox.innerHTML = '', this.options.hideMediaTimeout);
this.removeSkewing();
}
/**
* Transform cursor to image mode.
*
* @param {string} url Image url.
*/
setImg(url) {
if (!this.mediaImg) this.mediaImg = new Image();
if (this.mediaImg.src !== url) this.mediaImg.src = url;
this.setMedia(this.mediaImg);
}
/**
* Reverts cursor from image mode.
*/
removeImg() {
this.removeMedia();
}
/**
* Transform cursor to video mode.
*
* @param {string} url Video url.
*/
setVideo(url) {
if (!this.mediaVideo) {
this.mediaVideo = document.createElement('video');
this.mediaVideo.muted = true;
this.mediaVideo.loop = true;
this.mediaVideo.autoplay = true;
}
if (this.mediaVideo.src !== url) {
this.mediaVideo.src = url;
this.mediaVideo.load();
}
this.mediaVideo.play();
this.setMedia(this.mediaVideo);
}
/**
* Reverts cursor from video mode.
*/
removeVideo() {
if (this.mediaVideo && this.mediaVideo.readyState > 2) this.mediaVideo.pause();
this.removeMedia();
}
/**
* Attach an event handler function.
*
* @param {string} event Event name.
* @param {function} callback Callback.
*/
on(event, callback) {
if (!(this.events[event] instanceof Array)) this.off(event);
this.events[event].push(callback);
}
/**
* Remove an event handler.
*
* @param {string} event Event name.
* @param {function} [callback] Callback.
*/
off(event, callback) {
if (callback) {
this.events[event] = this.events[event].filter((f) => f !== callback);
} else {
this.events[event] = [];
}
}
/**
* Execute all handlers for the given event type.
*
* @param {string} event Event name.
* @param params Extra parameters.
*/
trigger(event, ...params) {
if (!this.events[event]) return;
this.events[event].forEach((f) => f.call(this, this, ...params));
}
/**
* Get cursor options from data attribute of a given element.
*
* @param {HTMLElement} element Element.
* @return {Object} Options.
*/
getFromDataset(element) {
const dataset = element.dataset;
return {
state: dataset[this.options.dataAttr],
show: dataset[this.options.dataAttr + 'Show'],
text: dataset[this.options.dataAttr + 'Text'],
icon: dataset[this.options.dataAttr + 'Icon'],
img: dataset[this.options.dataAttr + 'Img'],
video: dataset[this.options.dataAttr + 'Video'],
stick: dataset[this.options.dataAttr + 'Stick'],
};
}
/**
* Destroy cursor instance.
*/
destroy() {
this.trigger('destroy');
this.gsap.ticker.remove(this.ticker);
this.container.removeEventListener('mouseleave', this.event.mouseleave);
this.container.removeEventListener('mouseenter', this.event.mouseenter);
this.container.removeEventListener('mousedown', this.event.mousedown);
this.container.removeEventListener('mouseup', this.event.mouseup);
this.container.removeEventListener('mousemove', this.event.mousemove);
this.container.removeEventListener('mousemove', this.event.mousemoveOnce);
this.container.removeEventListener('mouseover', this.event.mouseover);
this.container.removeEventListener('mouseout', this.event.mouseout);
if (this.el) {
this.container.removeChild(this.el);
this.el = null;
this.mediaImg = null;
this.mediaVideo = null;
}
}
}