UNPKG

scrollcue.js

Version:

A lightweight scroll animation library using Intersection Observer API with advanced transform animations

440 lines (386 loc) 13.3 kB
(function() { 'use strict'; // Animation definitions - all animations are defined here const animations = { 'fade-in': { css: ` .scrollcue.fade-in.is-inactive { opacity: 0; } .scrollcue.fade-in.cue-in { animation-name: fadeIn; } @keyframes fadeIn { 0% { opacity: 0; } 100% { opacity: 1; } } ` }, 'fade-up': { css: ` .scrollcue.fade-up.is-inactive { opacity: 0; transform: translateY(20px); } .scrollcue.fade-up.cue-in { animation-name: fadeUp; } @keyframes fadeUp { 0% { opacity: 0; transform: translateY(20px); } 100% { opacity: 1; transform: translateY(0); } } ` }, 'slide-up': { css: ` .scrollcue.slide-up.is-inactive { transform: translateY(100px); opacity: 0; } .scrollcue.slide-up.cue-in { animation-name: slideUp; } @keyframes slideUp { 0% { transform: translateY(100px); opacity: 0; } 100% { transform: translateY(0); opacity: 1; } } ` }, 'bounce-in': { css: ` .scrollcue.bounce-in.is-inactive { transform: scale(0.3); opacity: 0; } .scrollcue.bounce-in.cue-in { animation-name: bounceIn; } @keyframes bounceIn { 0% { transform: scale(0.3); opacity: 0; } 50% { transform: scale(1.05); opacity: 0.8; } 70% { transform: scale(0.9); opacity: 0.9; } 100% { transform: scale(1); opacity: 1; } } ` }, 'fade-split': { css: ` .scrollcue.fade-split { position: relative; overflow: hidden; display: inline-block; } .scrollcue.fade-split .fade-split-left, .scrollcue.fade-split .fade-split-right { display: inline-block; position: relative; margin: 0; padding: 0; transition: transform 0.7s cubic-bezier(0.33,1,0.68,1), opacity 0.7s cubic-bezier(0.33,1,0.68,1); } .scrollcue.fade-split.is-inactive .fade-split-left { transform: translateX(-200%); opacity: 0; } .scrollcue.fade-split.is-inactive .fade-split-right { transform: translateX(200%); opacity: 0; } .scrollcue.fade-split.cue-in .fade-split-left { transform: translateX(0); opacity: 1; transition-delay: 0s; } .scrollcue.fade-split.cue-in .fade-split-right { transform: translateX(0); opacity: 1; transition-delay: 0.1s; } ` }, 'typing': { css: ` .scrollcue.typing { position: relative; overflow: hidden; } .scrollcue.typing .typing-cursor { animation: blink-caret 1s infinite; color: currentColor; font-size: inherit; line-height: inherit; } @keyframes blink-caret { 0%, 50% { opacity: 1; } 51%, 100% { opacity: 0; } } `, js: true }, 'stagger': { css: ` .scrollcue-child.is-inactive { opacity: 0; transform: translateY(20px); } .scrollcue-child.cue-in { animation-name: staggerFadeIn; } @keyframes staggerFadeIn { 0% { opacity: 0; transform: translateY(20px); } 100% { opacity: 1; transform: translateY(0); } } `, js: true } }; // Default options const defaults = { rootMargin: '0px', threshold: 0.2, duration: 800, delay: 0, easing: 'cubic-bezier(0.25, 0.1, 0.25, 1.0)', once: true, useRAF: true }; class ScrollCue { constructor(options = {}) { this.options = Object.assign({}, defaults, options); this.elements = []; this.observer = null; this.initialized = false; this.usedAnimations = new Set(); } init() { if (this.initialized) return; // Find all elements with scrollcue class const elements = document.querySelectorAll('.scrollcue'); // First pass: collect used animations elements.forEach(element => { const animType = element.dataset.cue || 'fade-in'; this.usedAnimations.add(animType); // Special handling for stagger - prepare children if (animType === 'stagger') { const childCue = element.dataset.childCue || 'fade-in'; this.usedAnimations.add(childCue); // Also add the child animation } }); // Inject only used animations this.injectAnimations(); // Second pass: process elements elements.forEach(element => { const animType = element.dataset.cue || 'fade-in'; // Special handling for fade-split if (animType === 'fade-split') { const content = element.textContent.trim(); const midPoint = Math.ceil(content.length / 2); const leftContent = content.substring(0, midPoint); const rightContent = content.substring(midPoint); element.innerHTML = `<span class="fade-split-left">${leftContent}</span><span class="fade-split-right">${rightContent}</span>`; } }); // Initialize Intersection Observer this.observer = new IntersectionObserver( (entries) => this.handleIntersection(entries), { rootMargin: this.options.rootMargin, threshold: this.options.threshold } ); // Add elements elements.forEach(element => { this.addElement(element); }); this.initialized = true; return this; } injectAnimations() { const styleEl = document.createElement('style'); // Add only used animations const css = Array.from(this.usedAnimations) .map(anim => animations[anim]?.css) .filter(Boolean) .join('\n'); styleEl.textContent = css; document.head.appendChild(styleEl); } addElement(element) { if (!element.classList.contains('scrollcue')) { element.classList.add('scrollcue'); } const animationType = element.dataset.cue || 'fade-in'; // Don't add is-inactive for stagger animations (they handle children separately) if (animationType !== 'stagger' && !element.classList.contains('is-inactive')) { element.classList.add('is-inactive'); } this.elements.push(element); this.observer.observe(element); return this; } handleIntersection(entries) { entries.forEach(entry => { if (entry.isIntersecting) { this.animateElement(entry.target); if (this.options.once) { this.observer.unobserve(entry.target); } } }); } animateElement(element) { const animationType = element.dataset.cue || 'fade-in'; // For stagger animations, skip normal element animation and handle children if (animationType === 'stagger') { this.startElementAnimation(element, animationType); return; } const delay = parseInt(element.dataset.delay || this.options.delay, 10); const duration = parseInt(element.dataset.duration || this.options.duration, 10); element.style.animationDuration = `${duration}ms`; element.style.animationDelay = `${delay}ms`; element.style.animationTimingFunction = this.options.easing; if (this.options.useRAF) { requestAnimationFrame(() => { this.startElementAnimation(element, animationType); }); } else { setTimeout(() => { this.startElementAnimation(element, animationType); }, 10); } } startElementAnimation(element, animationType) { // For stagger animations, don't add classes to parent element if (animationType !== 'stagger') { element.classList.remove('is-inactive'); element.classList.add('cue-in', animationType); } // Handle JavaScript-based animations if (animations[animationType] && animations[animationType].js) { this.handleJSAnimation(element, animationType); } element.dispatchEvent(new CustomEvent('scrollcue:start', { bubbles: true, detail: { element } })); } handleJSAnimation(element, animationType) { if (animationType === 'typing') { this.handleTypingAnimation(element); } else if (animationType === 'stagger') { this.handleStaggerAnimation(element); } } handleTypingAnimation(element) { const text = element.textContent; const speed = parseInt(element.dataset.typingSpeed || '100', 10); // milliseconds per character const showCursor = element.dataset.cursor === 'true'; // Store original content and clear for typing effect const originalText = text; element.textContent = ''; // Ensure the element maintains its original size element.style.width = element.offsetWidth + 'px'; element.style.overflow = 'hidden'; // Create cursor element if needed let cursorElement = null; if (showCursor) { cursorElement = document.createElement('span'); cursorElement.textContent = '|'; cursorElement.className = 'typing-cursor'; element.appendChild(cursorElement); } let charIndex = 0; const typeChar = () => { if (charIndex < originalText.length) { // Update text content const currentText = originalText.substring(0, charIndex + 1); if (cursorElement) { element.textContent = currentText; element.appendChild(cursorElement); } else { element.textContent = currentText; } charIndex++; setTimeout(typeChar, speed); } else { // Typing complete if (cursorElement) { element.textContent = originalText; element.appendChild(cursorElement); } else { element.textContent = originalText; } element.dispatchEvent(new CustomEvent('scrollcue:typing-complete', { bubbles: true, detail: { element } })); } }; // Start typing after a brief delay setTimeout(typeChar, 100); } handleStaggerAnimation(element) { const staggerDelay = parseInt(element.dataset.stagger || '100', 10); const childCue = element.dataset.childCue || 'fade-in'; const children = Array.from(element.children); // Add scrollcue-child class to children and set them as inactive children.forEach((child, index) => { child.classList.add('scrollcue-child', 'is-inactive', childCue); // Calculate delay for this child const delay = index * staggerDelay; // Apply animation after delay setTimeout(() => { child.classList.remove('is-inactive'); child.classList.add('cue-in'); // Set animation duration and timing const duration = parseInt(element.dataset.duration || this.options.duration, 10); child.style.animationDuration = `${duration}ms`; child.style.animationTimingFunction = this.options.easing; child.dispatchEvent(new CustomEvent('scrollcue:child-start', { bubbles: true, detail: { element: child, index } })); }, delay); }); } refresh() { if (!this.initialized) return this; this.elements.forEach(element => { this.observer.unobserve(element); }); this.elements = []; this.init(); return this; } destroy() { if (!this.initialized) return; this.elements.forEach(element => { this.observer.unobserve(element); }); this.elements = []; this.observer.disconnect(); this.initialized = false; } } // Export for different module systems if (typeof module !== 'undefined' && module.exports) { module.exports = ScrollCue; } else if (typeof define === 'function' && define.amd) { define([], function() { return ScrollCue; }); } else { window.ScrollCue = ScrollCue; } // Auto-initialize by default unless explicitly disabled if (!document.currentScript?.hasAttribute('data-no-auto-init')) { new ScrollCue().init(); } })();