UNPKG

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
/*! * 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; } } }