UNPKG

@magic-spells/scroll-trigger

Version:

Lightweight scroll-trigger plugin for tracking section visibility and syncing navigation state

451 lines (386 loc) 12.7 kB
import "./scroll-trigger.css"; /** * ScrollTrigger - Scroll spy plugin for tracking section visibility * * Monitors when sections cross a configurable trigger line in the viewport * (measured from the bottom) and provides callbacks for navigation synchronization. * * @example * const trigger = new ScrollTrigger({ * sections: '.collection-section', * offset: 100, // 100px from bottom of viewport * onIndexChange: ({ currentIndex, currentElement }) => { * console.log('Active section:', currentIndex); * } * }); */ class ScrollTrigger { // Private fields #elements = []; #currentIndex = -1; #observer = null; #config = { offset: 100, threshold: 0.1, throttle: 50, behavior: "smooth", onIndexChange: null, }; #intersectingMap = new Map(); #throttleTimer = null; #isDestroyed = false; #resizeObserver = null; #scrollHandler = null; /** * Create a new ScrollTrigger instance * @param {Object} options - Configuration options * @param {string|NodeList|Array} options.sections - Sections to track (CSS selector, NodeList, or Array of elements) * @param {number|string} [options.offset=100] - Distance from bottom of viewport to trigger active state (px or percentage like '20%') * @param {number} [options.threshold=0.1] - IntersectionObserver threshold (0-1) * @param {number} [options.throttle=100] - Throttle delay for scroll events (ms) * @param {string} [options.behavior='smooth'] - Scroll behavior ('smooth' or 'auto') * @param {Function} [options.onIndexChange] - Callback when active section changes (receives object: { currentIndex, previousIndex, currentElement, previousElement }) */ constructor(options = {}) { // Merge config this.#config = { ...this.#config, ...options }; // Get elements this.#elements = this.#getElements(this.#config.sections); if (this.#elements.length === 0) { console.warn("ScrollTrigger: No elements found"); return; } // Initialize observer this.#setupObserver(); // Watch for viewport resize if any element uses percentage offset if (this.#hasPercentageOffsets()) { this.#setupResizeObserver(); } // Add scroll listener as fallback for mobile this.#setupScrollListener(); } /** * Get elements from various input types */ #getElements(input) { if (typeof input === "string") { return Array.from(document.querySelectorAll(input)); } else if (input instanceof NodeList) { return Array.from(input); } else if (Array.isArray(input)) { return input; } return []; } /** * Check if offset is a percentage value */ #isPercentageOffset(offset) { return typeof offset === "string" && offset.includes("%"); } /** * Calculate pixel offset from bottom of viewport * Converts percentage or pixel value to pixels from bottom */ #calculateOffset(offset) { if (this.#isPercentageOffset(offset)) { const percentage = parseFloat(offset) / 100; return Math.round(window.innerHeight * percentage); } return typeof offset === "number" ? offset : 100; } /** * Get the offset for a specific element (custom or default) * Checks for data-animate-offset attribute, falls back to config */ #getElementOffset(element) { if (!element) return this.#config.offset; const customOffset = element.getAttribute("data-animate-offset"); if (customOffset !== null) { // Parse as number if it's just digits, otherwise return as string (for percentages) return /^\d+$/.test(customOffset) ? parseInt(customOffset, 10) : customOffset; } return this.#config.offset; } /** * Check if any element (or config) uses a percentage offset */ #hasPercentageOffsets() { // Check global config if (this.#isPercentageOffset(this.#config.offset)) { return true; } // Check if any element has a custom percentage offset return this.#elements.some((element) => { const customOffset = element.getAttribute("data-animate-offset"); return customOffset && this.#isPercentageOffset(customOffset); }); } /** * Setup ResizeObserver to handle viewport changes with percentage offsets */ #setupResizeObserver() { this.#resizeObserver = new ResizeObserver(() => { // Recreate observer with new calculated offset if (this.#observer) { this.#observer.disconnect(); } this.#setupObserver(); }); this.#resizeObserver.observe(document.documentElement); } /** * Setup scroll listener as fallback for mobile browsers * IntersectionObserver can miss events during momentum scrolling */ #setupScrollListener() { this.#scrollHandler = () => { this.#throttleIndexUpdate(); }; window.addEventListener("scroll", this.#scrollHandler, { passive: true }); } /** * Setup IntersectionObserver to track sections */ #setupObserver() { // Calculate pixel offset from bottom (handles both px and %) const offsetPx = this.#calculateOffset(this.#config.offset); // Create observer with offset from bottom // Top margin removes everything above, bottom margin creates trigger zone const rootMargin = `0px 0px -${offsetPx}px 0px`; this.#observer = new IntersectionObserver( (entries) => this.#handleIntersection(entries), { root: null, rootMargin: rootMargin, threshold: this.#config.threshold, }, ); // Observe all elements this.#elements.forEach((element) => { this.#observer.observe(element); this.#intersectingMap.set(element, false); }); } /** * Handle intersection events */ #handleIntersection(entries) { if (this.#isDestroyed) return; // Update intersecting map entries.forEach((entry) => { this.#intersectingMap.set(entry.target, entry.isIntersecting); }); // Throttle index calculation this.#throttleIndexUpdate(); } /** * Throttle index updates to prevent excessive calls */ #throttleIndexUpdate() { if (this.#throttleTimer) return; this.#throttleTimer = setTimeout(() => { this.#updateActiveIndex(); this.#throttleTimer = null; }, this.#config.throttle); } /** * Update the active index based on intersecting elements * Supports per-element custom offsets via data-animate-offset attribute */ #updateActiveIndex() { if (this.#isDestroyed) return; // Check each element against its custom offset let newIndex = -1; // Find the closest element that has crossed its trigger line // Check from bottom to top to find the last one that crossed for (let i = this.#elements.length - 1; i >= 0; i--) { const element = this.#elements[i]; const elementOffset = this.#getElementOffset(element); const offsetPx = this.#calculateOffset(elementOffset); const triggerLine = window.innerHeight - offsetPx; const rect = element.getBoundingClientRect(); // If this element's top is at or above its trigger line, it's active if (rect.top <= triggerLine) { newIndex = i; break; } } // Fire callback if index changed if (newIndex !== this.#currentIndex) { const previousIndex = this.#currentIndex; this.#currentIndex = newIndex; const currentElement = this.#elements[newIndex] || null; const previousElement = this.#elements[previousIndex] || null; if ( this.#config.onIndexChange && typeof this.#config.onIndexChange === "function" ) { this.#config.onIndexChange({ currentIndex: newIndex, previousIndex, currentElement, previousElement, }); } // Emit custom event this.#emitEvent("scroll-trigger:change", { index: newIndex, previousIndex, section: currentElement, previousSection: previousElement, }); } } /** * Emit custom event */ #emitEvent(eventName, detail) { if (typeof window !== "undefined") { window.dispatchEvent( new CustomEvent(eventName, { bubbles: true, detail: detail, }), ); } } /** * Get the current active index * @returns {number} Current active element index (-1 if none) */ getCurrentIndex() { return this.#currentIndex; } /** * Get the current active element * @returns {Element|null} Current active element or null */ getCurrentElement() { return this.#elements[this.#currentIndex] || null; } /** * Get all tracked elements * @returns {Array<Element>} Array of tracked elements */ getElements() { return [...this.#elements]; } /** * Scroll to a specific element by index * @param {number} index - Index of element to scroll to * @param {Object} [options] - Scroll options * @param {string} [options.behavior] - Scroll behavior ('smooth' or 'auto') * @param {number} [options.offset] - Additional offset in pixels (positive = element appears higher, negative = element appears lower) */ scrollToIndex(index, options = {}) { if (index < 0 || index >= this.#elements.length) { console.warn(`ScrollTrigger: Invalid index ${index}`); return; } const element = this.#elements[index]; const behavior = options.behavior || this.#config.behavior; const additionalOffset = options.offset || 0; // Calculate offset from bottom in pixels (respects element's custom offset) const elementOffset = this.#getElementOffset(element); const offsetPx = this.#calculateOffset(elementOffset); const triggerLine = window.innerHeight - offsetPx; const rect = element.getBoundingClientRect(); const scrollTop = window.pageYOffset || document.documentElement.scrollTop; const targetPosition = rect.top + scrollTop - triggerLine - additionalOffset; window.scrollTo({ top: targetPosition, behavior: behavior, }); } /** * Scroll to a specific element * @param {Element} element - Element to scroll to * @param {Object} [options] - Scroll options (see scrollToIndex) */ scrollToElement(element, options = {}) { const index = this.#elements.indexOf(element); if (index === -1) { console.warn("ScrollTrigger: Element not found in tracked elements"); return; } this.scrollToIndex(index, options); } /** * Recalculate element positions (call if DOM changes) */ refresh() { if (this.#isDestroyed) return; // Re-observe elements to update positions this.#elements.forEach((element) => { this.#observer.unobserve(element); this.#observer.observe(element); }); // Force index update this.#updateActiveIndex(); } /** * Update configuration * @param {Object} newConfig - New configuration options */ updateConfig(newConfig = {}) { if (this.#isDestroyed) return; const needsObserverUpdate = ("offset" in newConfig && newConfig.offset !== this.#config.offset) || ("threshold" in newConfig && newConfig.threshold !== this.#config.threshold); this.#config = { ...this.#config, ...newConfig }; // Handle resize observer for percentage changes if ("offset" in newConfig) { const hasPercentages = this.#hasPercentageOffsets(); if (hasPercentages && !this.#resizeObserver) { this.#setupResizeObserver(); } else if (!hasPercentages && this.#resizeObserver) { this.#resizeObserver.disconnect(); this.#resizeObserver = null; } } if (needsObserverUpdate) { if (this.#observer) { this.#observer.disconnect(); } this.#setupObserver(); } } /** * Destroy the tracker and cleanup */ destroy() { if (this.#isDestroyed) return; this.#isDestroyed = true; // Clear throttle timer if (this.#throttleTimer) { clearTimeout(this.#throttleTimer); this.#throttleTimer = null; } // Disconnect observer if (this.#observer) { this.#observer.disconnect(); this.#observer = null; } // Disconnect resize observer if (this.#resizeObserver) { this.#resizeObserver.disconnect(); this.#resizeObserver = null; } // Remove scroll listener if (this.#scrollHandler) { window.removeEventListener("scroll", this.#scrollHandler); this.#scrollHandler = null; } // Clear maps and arrays this.#intersectingMap.clear(); this.#elements = []; this.#currentIndex = -1; } } // Export for external use export default ScrollTrigger;