UNPKG

@studio-freight/lenis

Version:

Lenis is a smooth scroll library to normalize and smooth the scrolling experience across devices

681 lines (621 loc) 23 kB
(function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : typeof define === 'function' && define.amd ? define(factory) : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.Lenis = factory()); })(this, (function () { 'use strict'; var version = "1.0.42"; // Clamp a value between a minimum and maximum value function clamp(min, input, max) { return Math.max(min, Math.min(input, max)) } // Linearly interpolate between two values using an amount (0 <= t <= 1) function lerp(x, y, t) { return (1 - t) * x + t * y } // http://www.rorydriscoll.com/2016/03/07/frame-rate-independent-damping-using-lerp/ function damp(x, y, lambda, dt) { return lerp(x, y, 1 - Math.exp(-lambda * dt)) } // Calculate the modulo of the dividend and divisor while keeping the result within the same sign as the divisor // https://anguscroll.com/just/just-modulo function modulo(n, d) { return ((n % d) + d) % d } // Animate class to handle value animations with lerping or easing class Animate { // Advance the animation by the given delta time advance(deltaTime) { if (!this.isRunning) return let completed = false; if (this.lerp) { this.value = damp(this.value, this.to, this.lerp * 60, deltaTime); if (Math.round(this.value) === this.to) { this.value = this.to; completed = true; } } else { this.currentTime += deltaTime; const linearProgress = clamp(0, this.currentTime / this.duration, 1); completed = linearProgress >= 1; const easedProgress = completed ? 1 : this.easing(linearProgress); this.value = this.from + (this.to - this.from) * easedProgress; } // Call the onUpdate callback with the current value and completed status this.onUpdate?.(this.value, completed); if (completed) { this.stop(); } } // Stop the animation stop() { this.isRunning = false; } // Set up the animation from a starting value to an ending value // with optional parameters for lerping, duration, easing, and onUpdate callback fromTo( from, to, { lerp = 0.1, duration = 1, easing = (t) => t, onStart, onUpdate } ) { this.from = this.value = from; this.to = to; this.lerp = lerp; this.duration = duration; this.easing = easing; this.currentTime = 0; this.isRunning = true; onStart?.(); this.onUpdate = onUpdate; } } function debounce(callback, delay) { let timer; return function () { let args = arguments; let context = this; clearTimeout(timer); timer = setTimeout(function () { callback.apply(context, args); }, delay); } } class Dimensions { constructor({ wrapper, content, autoResize = true, debounce: debounceValue = 250, } = {}) { this.wrapper = wrapper; this.content = content; if (autoResize) { this.debouncedResize = debounce(this.resize, debounceValue); if (this.wrapper === window) { window.addEventListener('resize', this.debouncedResize, false); } else { this.wrapperResizeObserver = new ResizeObserver(this.debouncedResize); this.wrapperResizeObserver.observe(this.wrapper); } this.contentResizeObserver = new ResizeObserver(this.debouncedResize); this.contentResizeObserver.observe(this.content); } this.resize(); } destroy() { this.wrapperResizeObserver?.disconnect(); this.contentResizeObserver?.disconnect(); window.removeEventListener('resize', this.debouncedResize, false); } resize = () => { this.onWrapperResize(); this.onContentResize(); } onWrapperResize = () => { if (this.wrapper === window) { this.width = window.innerWidth; this.height = window.innerHeight; } else { this.width = this.wrapper.clientWidth; this.height = this.wrapper.clientHeight; } } onContentResize = () => { if (this.wrapper === window) { this.scrollHeight = this.content.scrollHeight; this.scrollWidth = this.content.scrollWidth; } else { this.scrollHeight = this.wrapper.scrollHeight; this.scrollWidth = this.wrapper.scrollWidth; } } get limit() { return { x: this.scrollWidth - this.width, y: this.scrollHeight - this.height, } } } class Emitter { constructor() { this.events = {}; } emit(event, ...args) { let callbacks = this.events[event] || []; for (let i = 0, length = callbacks.length; i < length; i++) { callbacks[i](...args); } } on(event, cb) { // Add the callback to the event's callback list, or create a new list with the callback this.events[event]?.push(cb) || (this.events[event] = [cb]); // Return an unsubscribe function return () => { this.events[event] = this.events[event]?.filter((i) => cb !== i); } } off(event, callback) { this.events[event] = this.events[event]?.filter((i) => callback !== i); } destroy() { this.events = {}; } } const LINE_HEIGHT = 100 / 6; class VirtualScroll { constructor(element, { wheelMultiplier = 1, touchMultiplier = 1 }) { this.element = element; this.wheelMultiplier = wheelMultiplier; this.touchMultiplier = touchMultiplier; this.touchStart = { x: null, y: null, }; this.emitter = new Emitter(); window.addEventListener('resize', this.onWindowResize, false); this.onWindowResize(); this.element.addEventListener('wheel', this.onWheel, { passive: false }); this.element.addEventListener('touchstart', this.onTouchStart, { passive: false, }); this.element.addEventListener('touchmove', this.onTouchMove, { passive: false, }); this.element.addEventListener('touchend', this.onTouchEnd, { passive: false, }); } // Add an event listener for the given event and callback on(event, callback) { return this.emitter.on(event, callback) } // Remove all event listeners and clean up destroy() { this.emitter.destroy(); window.removeEventListener('resize', this.onWindowResize, false); this.element.removeEventListener('wheel', this.onWheel, { passive: false, }); this.element.removeEventListener('touchstart', this.onTouchStart, { passive: false, }); this.element.removeEventListener('touchmove', this.onTouchMove, { passive: false, }); this.element.removeEventListener('touchend', this.onTouchEnd, { passive: false, }); } // Event handler for 'touchstart' event onTouchStart = (event) => { const { clientX, clientY } = event.targetTouches ? event.targetTouches[0] : event; this.touchStart.x = clientX; this.touchStart.y = clientY; this.lastDelta = { x: 0, y: 0, }; this.emitter.emit('scroll', { deltaX: 0, deltaY: 0, event, }); } // Event handler for 'touchmove' event onTouchMove = (event) => { const { clientX, clientY } = event.targetTouches ? event.targetTouches[0] : event; const deltaX = -(clientX - this.touchStart.x) * this.touchMultiplier; const deltaY = -(clientY - this.touchStart.y) * this.touchMultiplier; this.touchStart.x = clientX; this.touchStart.y = clientY; this.lastDelta = { x: deltaX, y: deltaY, }; this.emitter.emit('scroll', { deltaX, deltaY, event, }); } onTouchEnd = (event) => { this.emitter.emit('scroll', { deltaX: this.lastDelta.x, deltaY: this.lastDelta.y, event, }); } // Event handler for 'wheel' event onWheel = (event) => { let { deltaX, deltaY, deltaMode } = event; const multiplierX = deltaMode === 1 ? LINE_HEIGHT : deltaMode === 2 ? this.windowWidth : 1; const multiplierY = deltaMode === 1 ? LINE_HEIGHT : deltaMode === 2 ? this.windowHeight : 1; deltaX *= multiplierX; deltaY *= multiplierY; deltaX *= this.wheelMultiplier; deltaY *= this.wheelMultiplier; this.emitter.emit('scroll', { deltaX, deltaY, event }); } onWindowResize = () => { this.windowWidth = window.innerWidth; this.windowHeight = window.innerHeight; } } class Lenis { constructor({ wrapper = window, content = document.documentElement, wheelEventsTarget = wrapper, eventsTarget = wheelEventsTarget, smoothWheel = true, syncTouch = false, syncTouchLerp = 0.075, touchInertiaMultiplier = 35, duration, easing = (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)), lerp = !duration && 0.1, infinite = false, orientation = 'vertical', gestureOrientation = 'vertical', touchMultiplier = 1, wheelMultiplier = 1, autoResize = true, __experimental__naiveDimensions = false, } = {}) { this.__isSmooth = false; this.__isScrolling = false; this.__isStopped = false; this.__isLocked = false; this.onVirtualScroll = ({ deltaX, deltaY, event }) => { if (event.ctrlKey) return; const isTouch = event.type.includes('touch'); const isWheel = event.type.includes('wheel'); const isTapToStop = this.options.syncTouch && isTouch && event.type === 'touchstart' && !this.isStopped && !this.isLocked; if (isTapToStop) { this.reset(); return; } const isClick = deltaX === 0 && deltaY === 0; const isUnknownGesture = (this.options.gestureOrientation === 'vertical' && deltaY === 0) || (this.options.gestureOrientation === 'horizontal' && deltaX === 0); if (isClick || isUnknownGesture) { return; } let composedPath = event.composedPath(); composedPath = composedPath.slice(0, composedPath.indexOf(this.rootElement)); if (!!composedPath.find((node) => { var _a, _b, _c, _d, _e; return ((_a = node.hasAttribute) === null || _a === void 0 ? void 0 : _a.call(node, 'data-lenis-prevent')) || (isTouch && ((_b = node.hasAttribute) === null || _b === void 0 ? void 0 : _b.call(node, 'data-lenis-prevent-touch'))) || (isWheel && ((_c = node.hasAttribute) === null || _c === void 0 ? void 0 : _c.call(node, 'data-lenis-prevent-wheel'))) || (((_d = node.classList) === null || _d === void 0 ? void 0 : _d.contains('lenis')) && !((_e = node.classList) === null || _e === void 0 ? void 0 : _e.contains('lenis-stopped'))); })) return; if (this.isStopped || this.isLocked) { event.preventDefault(); return; } this.isSmooth = (this.options.syncTouch && isTouch) || (this.options.smoothWheel && isWheel); if (!this.isSmooth) { this.isScrolling = false; this.animate.stop(); return; } event.preventDefault(); let delta = deltaY; if (this.options.gestureOrientation === 'both') { delta = Math.abs(deltaY) > Math.abs(deltaX) ? deltaY : deltaX; } else if (this.options.gestureOrientation === 'horizontal') { delta = deltaX; } const syncTouch = isTouch && this.options.syncTouch; const isTouchEnd = isTouch && event.type === 'touchend'; const hasTouchInertia = isTouchEnd && Math.abs(delta) > 5; if (hasTouchInertia) { delta = this.velocity * this.options.touchInertiaMultiplier; } this.scrollTo(this.targetScroll + delta, Object.assign({ programmatic: false }, (syncTouch ? { lerp: hasTouchInertia ? this.options.syncTouchLerp : 1, } : { lerp: this.options.lerp, duration: this.options.duration, easing: this.options.easing, }))); }; this.onNativeScroll = () => { if (this.__preventNextScrollEvent) return; if (!this.isScrolling) { const lastScroll = this.animatedScroll; this.animatedScroll = this.targetScroll = this.actualScroll; this.velocity = 0; this.direction = Math.sign(this.animatedScroll - lastScroll); this.emit(); } }; window.lenisVersion = version; if (wrapper === document.documentElement || wrapper === document.body) { wrapper = window; } this.options = { wrapper, content, wheelEventsTarget, eventsTarget, smoothWheel, syncTouch, syncTouchLerp, touchInertiaMultiplier, duration, easing, lerp, infinite, gestureOrientation, orientation, touchMultiplier, wheelMultiplier, autoResize, __experimental__naiveDimensions, }; this.animate = new Animate(); this.emitter = new Emitter(); this.dimensions = new Dimensions({ wrapper, content, autoResize }); this.toggleClassName('lenis', true); this.velocity = 0; this.isLocked = false; this.isStopped = false; this.isSmooth = syncTouch || smoothWheel; this.isScrolling = false; this.targetScroll = this.animatedScroll = this.actualScroll; this.options.wrapper.addEventListener('scroll', this.onNativeScroll, false); this.virtualScroll = new VirtualScroll(eventsTarget, { touchMultiplier, wheelMultiplier, }); this.virtualScroll.on('scroll', this.onVirtualScroll); } destroy() { this.emitter.destroy(); this.options.wrapper.removeEventListener('scroll', this.onNativeScroll, false); this.virtualScroll.destroy(); this.dimensions.destroy(); this.toggleClassName('lenis', false); this.toggleClassName('lenis-smooth', false); this.toggleClassName('lenis-scrolling', false); this.toggleClassName('lenis-stopped', false); this.toggleClassName('lenis-locked', false); } on(event, callback) { return this.emitter.on(event, callback); } off(event, callback) { return this.emitter.off(event, callback); } setScroll(scroll) { if (this.isHorizontal) { this.rootElement.scrollLeft = scroll; } else { this.rootElement.scrollTop = scroll; } } resize() { this.dimensions.resize(); } emit() { this.emitter.emit('scroll', this); } reset() { this.isLocked = false; this.isScrolling = false; this.animatedScroll = this.targetScroll = this.actualScroll; this.velocity = 0; this.animate.stop(); } start() { if (!this.isStopped) return; this.isStopped = false; this.reset(); } stop() { if (this.isStopped) return; this.isStopped = true; this.animate.stop(); this.reset(); } raf(time) { const deltaTime = time - (this.time || time); this.time = time; this.animate.advance(deltaTime * 0.001); } scrollTo(target, { offset = 0, immediate = false, lock = false, duration = this.options.duration, easing = this.options.easing, lerp = !duration && this.options.lerp, onComplete, force = false, programmatic = true, } = {}) { if ((this.isStopped || this.isLocked) && !force) return; if (['top', 'left', 'start'].includes(target)) { target = 0; } else if (['bottom', 'right', 'end'].includes(target)) { target = this.limit; } else { let node; if (typeof target === 'string') { node = document.querySelector(target); } else if (target === null || target === void 0 ? void 0 : target.nodeType) { node = target; } if (node) { if (this.options.wrapper !== window) { const wrapperRect = this.options.wrapper.getBoundingClientRect(); offset -= this.isHorizontal ? wrapperRect.left : wrapperRect.top; } const rect = node.getBoundingClientRect(); target = (this.isHorizontal ? rect.left : rect.top) + this.animatedScroll; } } if (typeof target !== 'number') return; target += offset; target = Math.round(target); if (this.options.infinite) { if (programmatic) { this.targetScroll = this.animatedScroll = this.scroll; } } else { target = clamp(0, target, this.limit); } if (immediate) { this.animatedScroll = this.targetScroll = target; this.setScroll(this.scroll); this.reset(); onComplete === null || onComplete === void 0 ? void 0 : onComplete(this); return; } if (!programmatic) { if (target === this.targetScroll) return; this.targetScroll = target; } this.animate.fromTo(this.animatedScroll, target, { duration, easing, lerp, onStart: () => { if (lock) this.isLocked = true; this.isScrolling = true; }, onUpdate: (value, completed) => { this.isScrolling = true; this.velocity = value - this.animatedScroll; this.direction = Math.sign(this.velocity); this.animatedScroll = value; this.setScroll(this.scroll); if (programmatic) { this.targetScroll = value; } if (!completed) this.emit(); if (completed) { this.reset(); this.emit(); onComplete === null || onComplete === void 0 ? void 0 : onComplete(this); this.__preventNextScrollEvent = true; requestAnimationFrame(() => { delete this.__preventNextScrollEvent; }); } }, }); } get rootElement() { return this.options.wrapper === window ? document.documentElement : this.options.wrapper; } get limit() { if (this.options.__experimental__naiveDimensions) { if (this.isHorizontal) { return this.rootElement.scrollWidth - this.rootElement.clientWidth; } else { return this.rootElement.scrollHeight - this.rootElement.clientHeight; } } else { return this.dimensions.limit[this.isHorizontal ? 'x' : 'y']; } } get isHorizontal() { return this.options.orientation === 'horizontal'; } get actualScroll() { return this.isHorizontal ? this.rootElement.scrollLeft : this.rootElement.scrollTop; } get scroll() { return this.options.infinite ? modulo(this.animatedScroll, this.limit) : this.animatedScroll; } get progress() { return this.limit === 0 ? 1 : this.scroll / this.limit; } get isSmooth() { return this.__isSmooth; } set isSmooth(value) { if (this.__isSmooth !== value) { this.__isSmooth = value; this.toggleClassName('lenis-smooth', value); } } get isScrolling() { return this.__isScrolling; } set isScrolling(value) { if (this.__isScrolling !== value) { this.__isScrolling = value; this.toggleClassName('lenis-scrolling', value); } } get isStopped() { return this.__isStopped; } set isStopped(value) { if (this.__isStopped !== value) { this.__isStopped = value; this.toggleClassName('lenis-stopped', value); } } get isLocked() { return this.__isLocked; } set isLocked(value) { if (this.__isLocked !== value) { this.__isLocked = value; this.toggleClassName('lenis-locked', value); } } get className() { let className = 'lenis'; if (this.isStopped) className += ' lenis-stopped'; if (this.isLocked) className += ' lenis-locked'; if (this.isScrolling) className += ' lenis-scrolling'; if (this.isSmooth) className += ' lenis-smooth'; return className; } toggleClassName(name, value) { this.rootElement.classList.toggle(name, value); this.emitter.emit('className change', this); } } return Lenis; }));