diff --git a/README.md b/README.md index f82c3855..f396f70b 100644 --- a/README.md +++ b/README.md @@ -103,19 +103,24 @@ const lenis = new Lenis({ ## Instance settings -| Option | Type | Default | Description | -| ------------------ | ------------- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `wrapper` | `NodeElement` | `window` | Default element which has overflow | -| `content` | `NodeElement` | `document.body` | `wrapper`'s direct child | -| `duration` | `number` | `1.2` | Specifies the duration of the animation | -| `easing` | `function` | `(t) => Math.min(1, 1.001 - Math.pow(2, -10 * t))` | Specifies the rate of change of a specific value, our default is custom but you can pick one from [Easings.net](https://easings.net/en) | -| `direction` | `string` | `vertical` | `vertical` or `horizontal` scrolling. | -| `gestureDirection` | `string` | `vertical` | `vertical`, `horizontal` or `both`. | -| `smooth` | `boolean` | `true` | Enable or disable 'smoothness' | -| `mouseMultiplier` | `number` | `1` | This value is passed directly to [Virtual Scroll](https://github.com/ayamflow/virtual-scroll) | -| `smoothTouch` | `boolean` | `false` | Enable or disable 'smoothness' while scrolling using touch. Note: We have disabled it by default because touch devices native smoothness is impossible to mimic | -| `touchMultiplier` | `number` | `string` | This value is passed directly to [Virtual Scroll](https://github.com/ayamflow/virtual-scroll) | -| `infinite` | `boolean` | `false` | Enable infinite scrolling! | +| Option | Type | Default | Description | +| ------------------ | ------------- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `wrapper` | `NodeElement` | `window` | Default element which has overflow | +| `content` | `NodeElement` | `document.body` | `wrapper`'s direct child | +| `duration` | `number` | `1.2` | Specifies the duration of the animation | +| `easing` | `function` | `(t) => Math.min(1, 1.001 - Math.pow(2, -10 * t))` | Specifies the rate of change of a specific value, our default is custom but you can pick one from [Easings.net](https://easings.net/en) | +| `direction` | `string` | `vertical` | `vertical` or `horizontal` scrolling. | +| `gestureDirection` | `string` | `vertical` | `vertical`, `horizontal` or `both`. | +| `smooth` | `boolean` | `true` | Enable or disable 'smoothness' | +| `mouseMultiplier` | `number` | `1` | This value is passed directly to [Virtual Scroll](https://github.com/ayamflow/virtual-scroll) | +| `smoothTouch` | `boolean` | `false` | Enable or disable 'smoothness' while scrolling using touch. Note: We have disabled it by default because touch devices native smoothness is impossible to mimic | +| `touchMultiplier` | `number` | `string` | This value is passed directly to [Virtual Scroll](https://github.com/ayamflow/virtual-scroll) | +| `infinite` | `boolean` | `false` | Enable infinite scrolling! | +| `snapDuration` | `number` | `0.4` | Specifies the duration of the snap animation | +| `snapDelayOnScroll` | `number` | `0.4` | Specifies the delay of the snap animation after wheel scrolling | +| `snapDelayOnResize` | `number` | `0.1` | Specifies the delay of the snap animation after non-whell scrolling or resizing | +| `snapLength` | `number|string` | `20%` | Specifies the snap threshold value. Can be any size value `px`, `%`, `vh` or `vw` | +| `snapAlign` | `string` | `start` | Specifies the scroll-snap-align value. `start`, `end`, or `center` |
@@ -166,6 +171,15 @@ body {
scroll content
``` +#### Use `snap` attribute on nested scroll elements for scroll snap. It will override `snapAlign` setting value for the element. `snap-length` attribute can be used to override `snapLength` setting value. + +```html +
scroll content
+
scroll content
+
scroll content
+
scroll content
+``` + #### Manually use `lenis.scrollTo('#anchor')` on anchor link click ([see this issue](https://github.com/studio-freight/lenis/issues/19)) ```html diff --git a/src/lenis.js b/src/lenis.js index 0c350401..552b2a24 100644 --- a/src/lenis.js +++ b/src/lenis.js @@ -2,6 +2,7 @@ import { TinyEmitter as EventEmitter } from 'tiny-emitter' import VirtualScroll from 'virtual-scroll' import { version } from '../package.json' import { clamp, clampedModulo } from './maths' +import { getSnapLength } from './util' class Animate { to(target, { duration = 1, easing = (t) => t, ...keys } = {}) { @@ -28,17 +29,15 @@ class Animate { raf(deltaTime) { if (!this.isRunning) return - this.currentTime = Math.min(this.currentTime + deltaTime, this.duration) + this.currentTime += deltaTime - const progress = this.progress >= 1 ? 1 : this.easing(this.progress) + const progress = this.currentTime >= this.duration ? 1 : this.easing(this.currentTime / this.duration) this.keys.forEach((key) => { const from = this.fromKeys[key] const to = this.toKeys[key] - const value = from + (to - from) * progress - - this.target[key] = value + this.target[key] = from + (to - from) * progress }) if (progress === 1) { @@ -46,9 +45,6 @@ class Animate { } } - get progress() { - return this.currentTime / this.duration - } } export default class Lenis extends EventEmitter { @@ -56,6 +52,7 @@ export default class Lenis extends EventEmitter { * @typedef {(t: number) => number} EasingFunction * @typedef {'vertical' | 'horizontal'} Direction * @typedef {'vertical' | 'horizontal' | 'both'} GestureDirection + * @typedef {'start' | 'end' | 'center'} SnapAlign * * @typedef LenisOptions * @property {number} [duration] @@ -69,6 +66,11 @@ export default class Lenis extends EventEmitter { * @property {boolean} [infinite] * @property {Window | HTMLElement} [wrapper] * @property {HTMLElement} [content] + * @property {number} [snapDuration] + * @property {number} [snapDelayOnWheel] + * @property {number} [snapDelayOnResize] + * @property {string|number} [snapLength] + * @property {SnapAlign} [snapAlign] * * @param {LenisOptions} */ @@ -84,6 +86,11 @@ export default class Lenis extends EventEmitter { infinite = false, wrapper = window, content = document.body, + snapDuration = 0.4, + snapDelayOnWheel = 0.4, + snapDelayOnResize = 0.1, + snapLength = '20%', + snapAlign = 'start', // start, end, center } = {}) { super() @@ -115,7 +122,16 @@ export default class Lenis extends EventEmitter { this.wrapperNode = wrapper this.contentNode = content + this.snapAlign = snapAlign || 'start'; + this.snapLength = snapLength || 'start'; + this.snapDuration = snapDuration; + this.snapDelayOnWheelMS = snapDelayOnWheel * 1000; // MS => MilliSeconds + this.snapDelayOnResizeMS = snapDelayOnResize < 0.05 ? 100 : snapDelayOnResize * 1000 // Snap delay should be bigger than 50ms to avoid weird behavior + this.isHorizontal = this.direction === 'horizontal' + this.wrapperNode.addEventListener('scroll', this.onScroll) + this.wrapperNode.addEventListener('mousedown', this.onMouseDown) + this.wrapperNode.addEventListener('mouseup', this.onMouseUp) //observe wrapper node size if (this.wrapperNode === window) { @@ -162,6 +178,8 @@ export default class Lenis extends EventEmitter { }) this.virtualScroll.on(this.onVirtualScroll) + + this.initSnapElements() } get scrollProperty() { @@ -205,6 +223,7 @@ export default class Lenis extends EventEmitter { onWindowResize = () => { this.wrapperWidth = window.innerWidth this.wrapperHeight = window.innerHeight + this.scheduleSnap(this.snapDelayOnResizeMS) } onWrapperResize = ([entry]) => { @@ -212,6 +231,7 @@ export default class Lenis extends EventEmitter { const rect = entry.contentRect this.wrapperWidth = rect.width this.wrapperHeight = rect.height + this.scheduleSnap(this.snapDelayOnResizeMS) } } @@ -220,6 +240,7 @@ export default class Lenis extends EventEmitter { const rect = entry.contentRect this.contentWidth = rect.width this.contentHeight = rect.height + this.scheduleSnap(this.snapDelayOnResizeMS) } } @@ -276,6 +297,8 @@ export default class Lenis extends EventEmitter { // this.targetScroll = clamp(0, this.targetScroll, this.limit) this.scrollTo(this.targetScroll) + this.scheduleSnap(this.snapDelayOnWheelMS) + } raf(now) { @@ -287,11 +310,14 @@ export default class Lenis extends EventEmitter { this.lastScroll = this.scroll // where this.scroll is updated - this.animate.raf(deltaTime * 0.001) + if (!this.pause) { + this.animate.raf(deltaTime * 0.001) + } if (this.scroll === this.targetScroll) { // if target reached velocity should be 0 this.lastScroll = this.scroll + this.animate.stop() } if (this.isScrolling) { @@ -299,7 +325,7 @@ export default class Lenis extends EventEmitter { this.notify() } - this.isScrolling = this.scroll !== this.targetScroll + this.isScrolling = this.scroll !== this.lastScroll } get velocity() { @@ -323,6 +349,8 @@ export default class Lenis extends EventEmitter { this.lastScroll = this.wrapperNode[this.scrollProperty] + this.scheduleSnap(this.snapDelayOnResizeMS) + this.notify() } } @@ -410,4 +438,71 @@ export default class Lenis extends EventEmitter { }) } } + + initSnapElements() { + let allSnapElements = this.contentNode.querySelectorAll('[snap]'); + allSnapElements.forEach(snapElement => { + snapElement.snapAlign = snapElement.getAttribute('snap') || this.snapAlign; // start, end, center + snapElement.snapLength = getSnapLength(snapElement, snapElement.getAttribute('snap-length') || this.snapLength); + }) + + this.snapElements = allSnapElements; + } + + snap() { + if (!this.snapElements?.length) { + return + } + + let wrapperRect; + if (this.wrapperNode === window) { + wrapperRect = { + left: 0, + top: 0, + right: this.wrapperWidth, + bottom: this.wrapperHeight, + } + } else { + wrapperRect = this.wrapperNode.getBoundingClientRect(); + } + + this.snapElements.forEach(snapElement => { + const snapAlign = snapElement.snapAlign; // start, end, center + const snapLength = snapElement.snapLength; + const elRect = snapElement.getBoundingClientRect(); + + let delta; + if ('end' === snapAlign) { + delta = this.isHorizontal ? elRect.right - wrapperRect.right : elRect.bottom - wrapperRect.bottom + } else if ('center' === snapAlign) { + delta = this.isHorizontal ? (elRect.left - wrapperRect.left + (elRect.width - this.wrapperWidth) / 2) : (elRect.top - wrapperRect.top + (elRect.height - this.wrapperHeight) / 2) + } else { + // default type 'start' + delta = this.isHorizontal ? elRect.left - wrapperRect.left : elRect.top - wrapperRect.top; + } + + if (Math.abs(delta) <= snapLength) { + this.scrollTo(this.scroll + delta, { duration: this.snapDuration }); + return; + } + }) + } + + scheduleSnap(delay) { + if (this.snapTimer) { + clearTimeout(this.snapTimer) + } + + this.snapTimer = setTimeout(() => { + this.snap(); + }, delay); + } + + onMouseUp = () => { + this.pause = false; + } + + onMouseDown = () => { + this.pause = true; + } } diff --git a/src/util.js b/src/util.js new file mode 100644 index 00000000..2fb2ca99 --- /dev/null +++ b/src/util.js @@ -0,0 +1,45 @@ +export function getSnapLength(obj, snapVal, axis='horizontal') { + const clientSize = axis === 'horizontal' ? 'clientWidth' : 'clientHeight'; + + const declaration = parseUnitValue(snapVal) + // get y snap length based on declaration unit + if (declaration.unit === 'vh') { + return ( + (Math.max(document.documentElement.clientHeight, window.innerHeight || 1) / 100) * + declaration.value + ) + } else if (declaration.unit === 'vw') { + return ( + (Math.max(document.documentElement.clientWidth, window.innerWidth || 1) / 100) * + declaration.value + ) + } else if (declaration.unit === '%') { + return (obj[clientSize] / 100) * declaration.value + } else { + return declaration.value + } +} + +function parseUnitValue(unitValue) { + // regex to parse lengths + const regex = /([+-]?(?=\.\d|\d)(?:\d+)?(?:\.?\d*)(?:[eE][+-]?\d+)?)(px|%|vw|vh)/ + // defaults + let parsed = { + value: 0, + unit: 'px', + } + + if (typeof unitValue === 'number') { + parsed.value = unitValue + } else { + const match = regex.exec(unitValue) + if (match !== null) { + parsed = { + value: Number(match[1]), + unit: match[2], + } + } + } + + return parsed +} \ No newline at end of file