Spaces:
Running
Running
| /** | |
| * motion_interactive.js | |
| * | |
| * A comprehensive solution for implementing motion-based interactive elements | |
| * with behavioral patterns for the MorphGuard application. | |
| */ | |
| class MotionInteractive { | |
| /** | |
| * Creates a new motion interactive handler | |
| * @param {string|Element} selector - CSS selector or DOM element | |
| * @param {Object} options - Configuration options | |
| */ | |
| constructor(selector, options = {}) { | |
| // Handle both string selectors and direct element references | |
| this.element = typeof selector === 'string' | |
| ? document.querySelector(selector) | |
| : selector; | |
| if (!this.element) { | |
| console.error(`Element not found: ${selector}`); | |
| return; | |
| } | |
| // Default configuration | |
| this.config = { | |
| // Animation types | |
| animationType: 'fade', // 'fade', 'slide', 'scale', 'rotate', 'custom' | |
| // General animation parameters | |
| duration: 300, | |
| easing: 'ease-in-out', | |
| delay: 0, | |
| // Behavioral patterns | |
| behavior: { | |
| type: 'hover', // 'hover', 'click', 'scroll', 'inview', 'sequence', 'auto' | |
| repeat: false, | |
| interval: 3000, // For auto behavior | |
| threshold: 0.5, // For inview behavior (0-1) | |
| pauseOnHover: true | |
| }, | |
| // Interaction feedback | |
| feedback: { | |
| sound: false, | |
| soundSrc: '', | |
| haptic: false, | |
| visual: true, | |
| cursor: false, | |
| customCursor: '' | |
| }, | |
| // State management | |
| states: { | |
| initial: 'default', | |
| transitions: { | |
| // Example: default -> hover -> active -> default | |
| 'default': { next: 'hover' }, | |
| 'hover': { next: 'active' }, | |
| 'active': { next: 'default' } | |
| } | |
| }, | |
| // Advanced motion parameters | |
| motion: { | |
| path: null, // SVG path for motion along path | |
| physics: { | |
| enabled: false, | |
| gravity: 9.8, | |
| friction: 0.95, | |
| bounce: 0.8 | |
| }, | |
| transform: { | |
| translateX: 0, | |
| translateY: 0, | |
| translateZ: 0, | |
| rotateX: 0, | |
| rotateY: 0, | |
| rotateZ: 0, | |
| scaleX: 1, | |
| scaleY: 1, | |
| scaleZ: 1 | |
| } | |
| }, | |
| // Responsiveness | |
| responsive: { | |
| enabled: true, | |
| breakpoints: { | |
| small: { | |
| maxWidth: 576, | |
| settings: { duration: 200 } | |
| }, | |
| medium: { | |
| maxWidth: 768, | |
| settings: { duration: 250 } | |
| } | |
| } | |
| }, | |
| // Performance options | |
| performance: { | |
| useGPU: true, | |
| reduceMotion: false, // Respect user preference | |
| throttle: { | |
| enabled: true, | |
| fps: 60 | |
| } | |
| } | |
| }; | |
| // Merge user options with defaults | |
| this.config = this._mergeOptions(this.config, options); | |
| // State tracking | |
| this.state = { | |
| currentState: this.config.states.initial, | |
| isAnimating: false, | |
| lastFrameTime: 0, | |
| scrollY: 0, | |
| mousePosition: { x: 0, y: 0 }, | |
| touchPosition: { x: 0, y: 0 }, | |
| isInView: false, | |
| animationFrame: null, | |
| observers: {} | |
| }; | |
| // Store original styles | |
| this.originalStyles = { | |
| transform: this.element.style.transform || '', | |
| transition: this.element.style.transition || '', | |
| opacity: this.element.style.opacity || '1' | |
| }; | |
| // Initialize interactive element | |
| this._init(); | |
| } | |
| /** | |
| * Merges default options with user-provided options | |
| * @param {Object} defaults - Default options | |
| * @param {Object} userOptions - User-provided options | |
| * @returns {Object} - Merged options | |
| * @private | |
| */ | |
| _mergeOptions(defaults, userOptions) { | |
| const result = { ...defaults }; | |
| for (const key in userOptions) { | |
| if (userOptions.hasOwnProperty(key)) { | |
| if (typeof userOptions[key] === 'object' && !Array.isArray(userOptions[key]) && | |
| result[key] && typeof result[key] === 'object') { | |
| result[key] = this._mergeOptions(result[key], userOptions[key]); | |
| } else { | |
| result[key] = userOptions[key]; | |
| } | |
| } | |
| } | |
| return result; | |
| } | |
| /** | |
| * Initialize motion interactive element | |
| * @private | |
| */ | |
| _init() { | |
| // Add base class | |
| this.element.classList.add('motion-interactive'); | |
| // Apply initial state class | |
| this._applyStateClass(this.state.currentState); | |
| // Check if reduced motion is preferred | |
| this._checkReducedMotionPreference(); | |
| // Set up responsive behavior | |
| if (this.config.responsive.enabled) { | |
| this._setupResponsiveBehavior(); | |
| } | |
| // Add interactive behaviors based on configuration | |
| this._setupBehavior(); | |
| // Set up intersection observer for in-view detection | |
| this._setupInViewDetection(); | |
| // Apply GPU acceleration if enabled | |
| if (this.config.performance.useGPU) { | |
| this._applyGPUAcceleration(); | |
| } | |
| // Setup custom cursor if enabled | |
| if (this.config.feedback.cursor && this.config.feedback.customCursor) { | |
| this._setupCustomCursor(); | |
| } | |
| } | |
| /** | |
| * Check if user prefers reduced motion | |
| * @private | |
| */ | |
| _checkReducedMotionPreference() { | |
| if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) { | |
| this.config.performance.reduceMotion = true; | |
| } | |
| // Add listener for preference change | |
| window.matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change', e => { | |
| this.config.performance.reduceMotion = e.matches; | |
| }); | |
| } | |
| /** | |
| * Apply state class to element | |
| * @param {string} state - State name | |
| * @private | |
| */ | |
| _applyStateClass(state) { | |
| // Remove all state classes | |
| this.element.classList.forEach(className => { | |
| if (className.startsWith('state-')) { | |
| this.element.classList.remove(className); | |
| } | |
| }); | |
| // Add new state class | |
| this.element.classList.add(`state-${state}`); | |
| } | |
| /** | |
| * Set up responsive behavior | |
| * @private | |
| */ | |
| _setupResponsiveBehavior() { | |
| // Apply initial responsive settings | |
| this._applyResponsiveSettings(); | |
| // Add resize listener | |
| window.addEventListener('resize', this._debounce(() => { | |
| this._applyResponsiveSettings(); | |
| }, 250)); | |
| } | |
| /** | |
| * Apply responsive settings based on current viewport | |
| * @private | |
| */ | |
| _applyResponsiveSettings() { | |
| const windowWidth = window.innerWidth; | |
| let appliedBreakpoint = null; | |
| // Check breakpoints from smallest to largest | |
| const breakpoints = this.config.responsive.breakpoints; | |
| for (const key in breakpoints) { | |
| if (breakpoints.hasOwnProperty(key)) { | |
| const breakpoint = breakpoints[key]; | |
| if (windowWidth <= breakpoint.maxWidth) { | |
| appliedBreakpoint = breakpoint; | |
| break; | |
| } | |
| } | |
| } | |
| // Apply breakpoint settings if found | |
| if (appliedBreakpoint && appliedBreakpoint.settings) { | |
| // Create a temporary config with the responsive settings | |
| const tempConfig = { ...this.config }; | |
| // Apply breakpoint settings (shallow merge) | |
| for (const key in appliedBreakpoint.settings) { | |
| if (appliedBreakpoint.settings.hasOwnProperty(key) && tempConfig.hasOwnProperty(key)) { | |
| if (typeof tempConfig[key] !== 'object' || Array.isArray(tempConfig[key])) { | |
| tempConfig[key] = appliedBreakpoint.settings[key]; | |
| } | |
| } | |
| } | |
| // Update animation parameters if needed | |
| this._updateAnimationParameters(tempConfig); | |
| } else { | |
| // Reset to default settings | |
| this._updateAnimationParameters(this.config); | |
| } | |
| } | |
| /** | |
| * Set up element behavior based on configuration | |
| * @private | |
| */ | |
| _setupBehavior() { | |
| const behaviorType = this.config.behavior.type; | |
| switch (behaviorType) { | |
| case 'hover': | |
| this._setupHoverBehavior(); | |
| break; | |
| case 'click': | |
| this._setupClickBehavior(); | |
| break; | |
| case 'scroll': | |
| this._setupScrollBehavior(); | |
| break; | |
| case 'inview': | |
| // Already set up by _setupInViewDetection | |
| break; | |
| case 'sequence': | |
| this._setupSequenceBehavior(); | |
| break; | |
| case 'auto': | |
| this._setupAutoBehavior(); | |
| break; | |
| } | |
| } | |
| /** | |
| * Set up hover behavior | |
| * @private | |
| */ | |
| _setupHoverBehavior() { | |
| this.element.addEventListener('mouseenter', () => { | |
| this._handleStateTransition(); | |
| this._animate('in'); | |
| }); | |
| this.element.addEventListener('mouseleave', () => { | |
| this._handleStateTransition(); | |
| this._animate('out'); | |
| }); | |
| } | |
| /** | |
| * Set up click behavior | |
| * @private | |
| */ | |
| _setupClickBehavior() { | |
| this.element.addEventListener('click', () => { | |
| this._handleStateTransition(); | |
| if (this.state.currentState === 'active') { | |
| this._animate('in'); | |
| } else { | |
| this._animate('out'); | |
| } | |
| // Provide haptic feedback if enabled | |
| if (this.config.feedback.haptic && navigator.vibrate) { | |
| navigator.vibrate(50); | |
| } | |
| }); | |
| } | |
| /** | |
| * Set up scroll behavior | |
| * @private | |
| */ | |
| _setupScrollBehavior() { | |
| window.addEventListener('scroll', this._throttle(() => { | |
| const scrollY = window.scrollY || window.pageYOffset; | |
| const delta = scrollY - this.state.scrollY; | |
| this.state.scrollY = scrollY; | |
| const scrollProgress = this._getScrollProgress(); | |
| this._animateWithProgress(scrollProgress); | |
| }, this.config.performance.throttle.enabled ? 1000 / this.config.performance.throttle.fps : 0)); | |
| } | |
| /** | |
| * Set up in-view detection with Intersection Observer | |
| * @private | |
| */ | |
| _setupInViewDetection() { | |
| // Skip if unsupported | |
| if (!('IntersectionObserver' in window)) return; | |
| const options = { | |
| root: null, | |
| rootMargin: '0px', | |
| threshold: this.config.behavior.threshold | |
| }; | |
| const observer = new IntersectionObserver((entries) => { | |
| entries.forEach(entry => { | |
| if (entry.target === this.element) { | |
| this.state.isInView = entry.isIntersecting; | |
| if (this.state.isInView) { | |
| if (this.config.behavior.type === 'inview') { | |
| this._animate('in'); | |
| } | |
| } else { | |
| if (this.config.behavior.type === 'inview') { | |
| this._animate('out'); | |
| } | |
| } | |
| } | |
| }); | |
| }, options); | |
| observer.observe(this.element); | |
| this.state.observers.inView = observer; | |
| } | |
| /** | |
| * Set up sequence behavior (animation sequence) | |
| * @private | |
| */ | |
| _setupSequenceBehavior() { | |
| // For elements that are part of a sequence | |
| // They need to be controlled externally via the API | |
| // No automatic triggers here | |
| } | |
| /** | |
| * Set up auto behavior (automatic animation) | |
| * @private | |
| */ | |
| _setupAutoBehavior() { | |
| let interval; | |
| const startAutoAnimation = () => { | |
| interval = setInterval(() => { | |
| this._handleStateTransition(); | |
| this._animate('toggle'); | |
| }, this.config.behavior.interval); | |
| }; | |
| // Start automatic animation | |
| startAutoAnimation(); | |
| // Pause on hover if configured | |
| if (this.config.behavior.pauseOnHover) { | |
| this.element.addEventListener('mouseenter', () => { | |
| clearInterval(interval); | |
| }); | |
| this.element.addEventListener('mouseleave', () => { | |
| startAutoAnimation(); | |
| }); | |
| } | |
| } | |
| /** | |
| * Set up custom cursor | |
| * @private | |
| */ | |
| _setupCustomCursor() { | |
| let cursor = document.querySelector('.custom-cursor'); | |
| // Create custom cursor if it doesn't exist | |
| if (!cursor) { | |
| cursor = document.createElement('div'); | |
| cursor.classList.add('custom-cursor'); | |
| document.body.appendChild(cursor); | |
| // Style for cursor | |
| cursor.style.position = 'fixed'; | |
| cursor.style.pointerEvents = 'none'; | |
| cursor.style.zIndex = '9999'; | |
| cursor.style.width = '20px'; | |
| cursor.style.height = '20px'; | |
| cursor.style.borderRadius = '50%'; | |
| cursor.style.backgroundColor = 'rgba(0, 0, 0, 0.2)'; | |
| cursor.style.transform = 'translate(-50%, -50%)'; | |
| cursor.style.transition = 'transform 0.1s ease-out'; | |
| cursor.style.display = 'none'; | |
| // If custom cursor is an image | |
| if (this.config.feedback.customCursor.endsWith('.png') || | |
| this.config.feedback.customCursor.endsWith('.svg') || | |
| this.config.feedback.customCursor.endsWith('.jpg')) { | |
| cursor.style.backgroundImage = `url(${this.config.feedback.customCursor})`; | |
| cursor.style.backgroundSize = 'contain'; | |
| cursor.style.backgroundRepeat = 'no-repeat'; | |
| } | |
| } | |
| // Show custom cursor when over the element | |
| this.element.addEventListener('mouseenter', () => { | |
| cursor.style.display = 'block'; | |
| document.body.style.cursor = 'none'; | |
| }); | |
| // Hide custom cursor when leaving the element | |
| this.element.addEventListener('mouseleave', () => { | |
| cursor.style.display = 'none'; | |
| document.body.style.cursor = ''; | |
| }); | |
| // Move custom cursor with mouse | |
| this.element.addEventListener('mousemove', (e) => { | |
| cursor.style.left = `${e.clientX}px`; | |
| cursor.style.top = `${e.clientY}px`; | |
| }); | |
| } | |
| /** | |
| * Handle state transition based on current state | |
| * @private | |
| */ | |
| _handleStateTransition() { | |
| const currentState = this.state.currentState; | |
| const transitions = this.config.states.transitions; | |
| if (transitions[currentState] && transitions[currentState].next) { | |
| this.state.currentState = transitions[currentState].next; | |
| this._applyStateClass(this.state.currentState); | |
| } | |
| } | |
| /** | |
| * Apply GPU acceleration to element | |
| * @private | |
| */ | |
| _applyGPUAcceleration() { | |
| // Force hardware acceleration | |
| this.element.style.willChange = 'transform'; | |
| this.element.style.transform = 'translateZ(0)'; | |
| } | |
| /** | |
| * Update animation parameters | |
| * @param {Object} config - Configuration object | |
| * @private | |
| */ | |
| _updateAnimationParameters(config) { | |
| this.element.style.transition = `all ${config.duration}ms ${config.easing} ${config.delay}ms`; | |
| } | |
| /** | |
| * Animate the element (in, out, or toggle) | |
| * @param {string} direction - Animation direction ('in', 'out', 'toggle') | |
| * @private | |
| */ | |
| _animate(direction) { | |
| // Skip animation if already animating or user prefers reduced motion | |
| if (this.state.isAnimating || this.config.performance.reduceMotion) { | |
| return; | |
| } | |
| this.state.isAnimating = true; | |
| // Play sound if enabled | |
| this._playFeedbackSound(); | |
| // Determine final state | |
| let targetState; | |
| if (direction === 'toggle') { | |
| targetState = this.element.classList.contains('animated-in') ? 'out' : 'in'; | |
| } else { | |
| targetState = direction; | |
| } | |
| // Apply animation based on type | |
| switch (this.config.animationType) { | |
| case 'fade': | |
| this._animateFade(targetState); | |
| break; | |
| case 'slide': | |
| this._animateSlide(targetState); | |
| break; | |
| case 'scale': | |
| this._animateScale(targetState); | |
| break; | |
| case 'rotate': | |
| this._animateRotate(targetState); | |
| break; | |
| case 'custom': | |
| this._animateCustom(targetState); | |
| break; | |
| } | |
| // Update animation state classes | |
| this.element.classList.remove('animated-in', 'animated-out'); | |
| this.element.classList.add(`animated-${targetState}`); | |
| // Reset animating flag after animation completes | |
| setTimeout(() => { | |
| this.state.isAnimating = false; | |
| // Trigger repeat if configured | |
| if (this.config.behavior.repeat && targetState === 'out') { | |
| setTimeout(() => { | |
| this._animate('in'); | |
| }, this.config.delay); | |
| } | |
| }, this.config.duration + this.config.delay); | |
| } | |
| /** | |
| * Animate with scroll progress (0-1) | |
| * @param {number} progress - Animation progress (0-1) | |
| * @private | |
| */ | |
| _animateWithProgress(progress) { | |
| // Clamp progress between 0 and 1 | |
| progress = Math.max(0, Math.min(1, progress)); | |
| // Apply animation based on type with progress | |
| switch (this.config.animationType) { | |
| case 'fade': | |
| this.element.style.opacity = progress; | |
| break; | |
| case 'slide': | |
| const distance = 100 * (1 - progress); | |
| this.element.style.transform = `translateY(${distance}px)`; | |
| break; | |
| case 'scale': | |
| const scale = 0.5 + (progress * 0.5); | |
| this.element.style.transform = `scale(${scale})`; | |
| break; | |
| case 'rotate': | |
| const rotation = 360 * progress; | |
| this.element.style.transform = `rotate(${rotation}deg)`; | |
| break; | |
| case 'custom': | |
| // Apply custom transform based on progress | |
| this._applyCustomTransform(progress); | |
| break; | |
| } | |
| } | |
| /** | |
| * Animate fade effect | |
| * @param {string} direction - Animation direction ('in', 'out') | |
| * @private | |
| */ | |
| _animateFade(direction) { | |
| this.element.style.opacity = direction === 'in' ? '1' : '0'; | |
| } | |
| /** | |
| * Animate slide effect | |
| * @param {string} direction - Animation direction ('in', 'out') | |
| * @private | |
| */ | |
| _animateSlide(direction) { | |
| const transform = this.config.motion.transform; | |
| const x = transform.translateX || 0; | |
| const y = transform.translateY || 0; | |
| if (direction === 'in') { | |
| this.element.style.transform = `translate(${x}px, ${y}px)`; | |
| this.element.style.opacity = '1'; | |
| } else { | |
| this.element.style.transform = `translate(${x}px, ${y + 100}px)`; | |
| this.element.style.opacity = '0'; | |
| } | |
| } | |
| /** | |
| * Animate scale effect | |
| * @param {string} direction - Animation direction ('in', 'out') | |
| * @private | |
| */ | |
| _animateScale(direction) { | |
| const transform = this.config.motion.transform; | |
| const scaleX = transform.scaleX || 1; | |
| const scaleY = transform.scaleY || 1; | |
| if (direction === 'in') { | |
| this.element.style.transform = `scale(${scaleX}, ${scaleY})`; | |
| this.element.style.opacity = '1'; | |
| } else { | |
| this.element.style.transform = `scale(${scaleX * 0.5}, ${scaleY * 0.5})`; | |
| this.element.style.opacity = '0'; | |
| } | |
| } | |
| /** | |
| * Animate rotate effect | |
| * @param {string} direction - Animation direction ('in', 'out') | |
| * @private | |
| */ | |
| _animateRotate(direction) { | |
| const transform = this.config.motion.transform; | |
| const rotateZ = transform.rotateZ || 0; | |
| if (direction === 'in') { | |
| this.element.style.transform = `rotate(${rotateZ}deg)`; | |
| this.element.style.opacity = '1'; | |
| } else { | |
| this.element.style.transform = `rotate(${rotateZ + 90}deg)`; | |
| this.element.style.opacity = '0'; | |
| } | |
| } | |
| /** | |
| * Apply custom animation | |
| * @param {string} direction - Animation direction ('in', 'out') | |
| * @private | |
| */ | |
| _animateCustom(direction) { | |
| // Apply transform based on direction | |
| this._applyCustomTransform(direction === 'in' ? 1 : 0); | |
| } | |
| /** | |
| * Apply custom transform based on animation progress | |
| * @param {number} progress - Animation progress (0-1) | |
| * @private | |
| */ | |
| _applyCustomTransform(progress) { | |
| const t = this.config.motion.transform; | |
| // Interpolate transform values | |
| const tx = (t.translateX || 0) * progress; | |
| const ty = (t.translateY || 0) * progress; | |
| const tz = (t.translateZ || 0) * progress; | |
| const rx = (t.rotateX || 0) * progress; | |
| const ry = (t.rotateY || 0) * progress; | |
| const rz = (t.rotateZ || 0) * progress; | |
| const sx = 1 + ((t.scaleX || 1) - 1) * progress; | |
| const sy = 1 + ((t.scaleY || 1) - 1) * progress; | |
| const sz = 1 + ((t.scaleZ || 1) - 1) * progress; | |
| // Apply transform | |
| this.element.style.transform = ` | |
| translate3d(${tx}px, ${ty}px, ${tz}px) | |
| rotateX(${rx}deg) rotateY(${ry}deg) rotateZ(${rz}deg) | |
| scale3d(${sx}, ${sy}, ${sz}) | |
| `.trim(); | |
| // Apply opacity | |
| this.element.style.opacity = 0.5 + (progress * 0.5); | |
| } | |
| /** | |
| * Get scroll progress relative to element | |
| * @returns {number} - Scroll progress (0-1) | |
| * @private | |
| */ | |
| _getScrollProgress() { | |
| const rect = this.element.getBoundingClientRect(); | |
| const windowHeight = window.innerHeight; | |
| if (rect.bottom < 0) { | |
| // Element is above viewport | |
| return 0; | |
| } else if (rect.top > windowHeight) { | |
| // Element is below viewport | |
| return 0; | |
| } else { | |
| // Element is partially or fully in viewport | |
| const visibleHeight = Math.min(rect.bottom, windowHeight) - Math.max(rect.top, 0); | |
| return Math.min(1, visibleHeight / rect.height); | |
| } | |
| } | |
| /** | |
| * Play feedback sound if enabled | |
| * @private | |
| */ | |
| _playFeedbackSound() { | |
| if (this.config.feedback.sound && this.config.feedback.soundSrc) { | |
| const audio = new Audio(this.config.feedback.soundSrc); | |
| audio.volume = 0.5; // Moderate volume | |
| // Play sound with error handling | |
| audio.play().catch(error => { | |
| console.warn('Could not play audio feedback:', error); | |
| }); | |
| } | |
| } | |
| /** | |
| * Debounce function to limit how often a function is called | |
| * @param {Function} func - Function to debounce | |
| * @param {number} wait - Wait time in milliseconds | |
| * @returns {Function} - Debounced function | |
| * @private | |
| */ | |
| _debounce(func, wait) { | |
| let timeout; | |
| return function() { | |
| const context = this; | |
| const args = arguments; | |
| clearTimeout(timeout); | |
| timeout = setTimeout(() => { | |
| func.apply(context, args); | |
| }, wait); | |
| }; | |
| } | |
| /** | |
| * Throttle function to limit frequency of function calls | |
| * @param {Function} func - Function to throttle | |
| * @param {number} limit - Minimum time between calls in milliseconds | |
| * @returns {Function} - Throttled function | |
| * @private | |
| */ | |
| _throttle(func, limit) { | |
| let inThrottle; | |
| return function() { | |
| const context = this; | |
| const args = arguments; | |
| if (!inThrottle) { | |
| func.apply(context, args); | |
| inThrottle = true; | |
| setTimeout(() => inThrottle = false, limit); | |
| } | |
| }; | |
| } | |
| // PUBLIC API METHODS | |
| /** | |
| * Manually trigger animation | |
| * @param {string} direction - Animation direction ('in', 'out', 'toggle') | |
| */ | |
| animate(direction) { | |
| this._animate(direction); | |
| } | |
| /** | |
| * Change current state | |
| * @param {string} stateName - Name of state to transition to | |
| */ | |
| setState(stateName) { | |
| // Validate state exists | |
| if (!this.config.states.transitions.hasOwnProperty(stateName)) { | |
| console.warn(`State "${stateName}" is not defined`); | |
| return; | |
| } | |
| this.state.currentState = stateName; | |
| this._applyStateClass(stateName); | |
| } | |
| /** | |
| * Update configuration options | |
| * @param {Object} newOptions - New configuration options | |
| */ | |
| updateConfig(newOptions) { | |
| this.config = this._mergeOptions(this.config, newOptions); | |
| // Re-initialize with new config | |
| this._updateAnimationParameters(this.config); | |
| } | |
| /** | |
| * Clean up resources and event listeners | |
| */ | |
| destroy() { | |
| // Remove all event listeners | |
| this.element.removeEventListener('mouseenter', this._animate); | |
| this.element.removeEventListener('mouseleave', this._animate); | |
| this.element.removeEventListener('click', this._animate); | |
| // Remove intersection observer | |
| if (this.state.observers.inView) { | |
| this.state.observers.inView.disconnect(); | |
| } | |
| // Remove custom cursor | |
| const cursor = document.querySelector('.custom-cursor'); | |
| if (cursor) { | |
| document.body.removeChild(cursor); | |
| } | |
| // Reset element style to original | |
| this.element.style.transform = this.originalStyles.transform; | |
| this.element.style.transition = this.originalStyles.transition; | |
| this.element.style.opacity = this.originalStyles.opacity; | |
| this.element.style.willChange = ''; | |
| // Remove classes | |
| this.element.classList.remove( | |
| 'motion-interactive', | |
| 'animated-in', | |
| 'animated-out', | |
| ...Array.from(this.element.classList).filter(cls => cls.startsWith('state-')) | |
| ); | |
| } | |
| } | |
| /** | |
| * MotionSequence - Orchestrates multiple motion elements in a sequence | |
| */ | |
| class MotionSequence { | |
| /** | |
| * Creates a new motion sequence | |
| * @param {Array} elements - Array of MotionInteractive instances or selectors | |
| * @param {Object} options - Configuration options | |
| */ | |
| constructor(elements, options = {}) { | |
| // Default configuration | |
| this.config = { | |
| sequenceDelay: 200, // Delay between sequence items | |
| staggered: true, // Whether to use staggered animation | |
| direction: 'forward', // 'forward', 'reverse', 'alternate' | |
| loop: false, // Whether to loop the sequence | |
| autoplay: false, // Whether to play automatically | |
| playTrigger: null, // CSS selector for element that triggers play | |
| resetWhenOffscreen: true, // Reset when sequence goes offscreen | |
| }; | |
| // Merge user options | |
| this.config = { ...this.config, ...options }; | |
| // Convert any string selectors to MotionInteractive instances | |
| this.elements = elements.map(element => { | |
| if (element instanceof MotionInteractive) { | |
| return element; | |
| } else { | |
| // For string selectors or DOM elements, create new instance | |
| return new MotionInteractive(element, { | |
| behavior: { type: 'sequence' } // Override behavior type | |
| }); | |
| } | |
| }); | |
| // State tracking | |
| this.state = { | |
| currentIndex: 0, | |
| direction: 1, // 1 for forward, -1 for reverse | |
| isPlaying: false, | |
| observers: {} | |
| }; | |
| // Initialize sequence | |
| this._init(); | |
| } | |
| /** | |
| * Initialize motion sequence | |
| * @private | |
| */ | |
| _init() { | |
| // Set up play trigger if configured | |
| if (this.config.playTrigger) { | |
| const trigger = document.querySelector(this.config.playTrigger); | |
| if (trigger) { | |
| trigger.addEventListener('click', () => this.play()); | |
| } | |
| } | |
| // Set up in-view detection for auto reset | |
| if (this.config.resetWhenOffscreen) { | |
| this._setupInViewDetection(); | |
| } | |
| // Autoplay if configured | |
| if (this.config.autoplay) { | |
| // Slight delay for DOM to be ready | |
| setTimeout(() => this.play(), 100); | |
| } | |
| } | |
| /** | |
| * Set up in-view detection with Intersection Observer | |
| * @private | |
| */ | |
| _setupInViewDetection() { | |
| // Skip if unsupported | |
| if (!('IntersectionObserver' in window) || this.elements.length === 0) return; | |
| // Use first element as reference for in-view detection | |
| const referenceElement = this.elements[0].element; | |
| const options = { | |
| root: null, | |
| rootMargin: '0px', | |
| threshold: 0.2 | |
| }; | |
| const observer = new IntersectionObserver((entries) => { | |
| entries.forEach(entry => { | |
| if (!entry.isIntersecting && this.state.isPlaying) { | |
| // Reset sequence when scrolled out of view | |
| this.reset(); | |
| } | |
| }); | |
| }, options); | |
| observer.observe(referenceElement); | |
| this.state.observers.inView = observer; | |
| } | |
| /** | |
| * Play the motion sequence | |
| */ | |
| play() { | |
| if (this.state.isPlaying) return; | |
| this.state.isPlaying = true; | |
| // Reset to start if needed | |
| if (this.config.direction !== 'reverse') { | |
| this.state.currentIndex = 0; | |
| this.state.direction = 1; | |
| } else { | |
| this.state.currentIndex = this.elements.length - 1; | |
| this.state.direction = -1; | |
| } | |
| this._playNextInSequence(); | |
| } | |
| /** | |
| * Play next item in sequence | |
| * @private | |
| */ | |
| _playNextInSequence() { | |
| if (!this.state.isPlaying) return; | |
| const currentElement = this.elements[this.state.currentIndex]; | |
| if (!currentElement) return; | |
| // Animate current element | |
| currentElement.animate('in'); | |
| // Set up next element in sequence | |
| const nextIndex = this._getNextIndex(); | |
| // If sequence is complete | |
| if (nextIndex === -1) { | |
| // If looping is enabled, reset and continue | |
| if (this.config.loop) { | |
| setTimeout(() => { | |
| // Handle direction for alternate mode | |
| if (this.config.direction === 'alternate') { | |
| this.state.direction *= -1; | |
| } | |
| // Reset index based on direction | |
| if (this.state.direction === 1) { | |
| this.state.currentIndex = 0; | |
| } else { | |
| this.state.currentIndex = this.elements.length - 1; | |
| } | |
| this._playNextInSequence(); | |
| }, this.config.sequenceDelay); | |
| } else { | |
| // Otherwise, end sequence | |
| this.state.isPlaying = false; | |
| } | |
| } else { | |
| // Continue to next element after delay | |
| setTimeout(() => { | |
| this.state.currentIndex = nextIndex; | |
| this._playNextInSequence(); | |
| }, this.config.sequenceDelay); | |
| } | |
| } | |
| /** | |
| * Get index of next element in sequence | |
| * @returns {number} - Index of next element or -1 if sequence complete | |
| * @private | |
| */ | |
| _getNextIndex() { | |
| let nextIndex = this.state.currentIndex + this.state.direction; | |
| // Check if sequence is complete | |
| if (nextIndex < 0 || nextIndex >= this.elements.length) { | |
| return -1; | |
| } | |
| return nextIndex; | |
| } | |
| /** | |
| * Pause the motion sequence | |
| */ | |
| pause() { | |
| this.state.isPlaying = false; | |
| } | |
| /** | |
| * Reset the sequence to initial state | |
| */ | |
| reset() { | |
| this.state.isPlaying = false; | |
| // Reset all elements to initial state | |
| this.elements.forEach(element => { | |
| element.animate('out'); | |
| }); | |
| } | |
| /** | |
| * Clean up resources | |
| */ | |
| destroy() { | |
| // Clean up intersection observer | |
| if (this.state.observers.inView) { | |
| this.state.observers.inView.disconnect(); | |
| } | |
| // Destroy all element instances | |
| this.elements.forEach(element => { | |
| element.destroy(); | |
| }); | |
| } | |
| } | |
| /** | |
| * Behavioral Pattern Library - Reusable motion patterns | |
| */ | |
| const MotionPatterns = { | |
| /** | |
| * Attention-seeking pulse animation | |
| * @param {string|Element} selector - Element selector | |
| * @param {Object} options - Additional options | |
| * @returns {MotionInteractive} - Motion interactive instance | |
| */ | |
| pulse: function(selector, options = {}) { | |
| return new MotionInteractive(selector, { | |
| animationType: 'scale', | |
| duration: 600, | |
| easing: 'cubic-bezier(0.25, 0.1, 0.25, 1)', | |
| behavior: { | |
| type: 'auto', | |
| repeat: true, | |
| interval: 2000, | |
| pauseOnHover: true | |
| }, | |
| motion: { | |
| transform: { | |
| scaleX: 1.05, | |
| scaleY: 1.05 | |
| } | |
| }, | |
| ...options | |
| }); | |
| }, | |
| /** | |
| * Floating animation that gently moves up and down | |
| * @param {string|Element} selector - Element selector | |
| * @param {Object} options - Additional options | |
| * @returns {MotionInteractive} - Motion interactive instance | |
| */ | |
| float: function(selector, options = {}) { | |
| return new MotionInteractive(selector, { | |
| animationType: 'custom', | |
| duration: 3000, | |
| easing: 'ease-in-out', | |
| behavior: { | |
| type: 'auto', | |
| repeat: true, | |
| interval: 3000 | |
| }, | |
| motion: { | |
| transform: { | |
| translateY: -10 | |
| } | |
| }, | |
| ...options | |
| }); | |
| }, | |
| /** | |
| * Reveal animation for content as it enters viewport | |
| * @param {string|Element} selector - Element selector | |
| * @param {Object} options - Additional options | |
| * @returns {MotionInteractive} - Motion interactive instance | |
| */ | |
| reveal: function(selector, options = {}) { | |
| return new MotionInteractive(selector, { | |
| animationType: 'fade', | |
| duration: 800, | |
| easing: 'cubic-bezier(0.5, 0, 0, 1)', | |
| behavior: { | |
| type: 'inview', | |
| threshold: 0.3 | |
| }, | |
| motion: { | |
| transform: { | |
| translateY: 20 | |
| } | |
| }, | |
| ...options | |
| }); | |
| }, | |
| /** | |
| * Create a staggered entrance for a group of elements | |
| * @param {string} containerSelector - Container element selector | |
| * @param {string} itemSelector - Individual item selector | |
| * @param {Object} options - Additional options | |
| * @returns {MotionSequence} - Motion sequence instance | |
| */ | |
| stagger: function(containerSelector, itemSelector, options = {}) { | |
| const container = document.querySelector(containerSelector); | |
| if (!container) return null; | |
| const items = Array.from(container.querySelectorAll(itemSelector)); | |
| const elements = items.map(item => new MotionInteractive(item, { | |
| animationType: options.animationType || 'fade', | |
| duration: options.duration || 500, | |
| easing: options.easing || 'ease-out', | |
| behavior: { type: 'sequence' }, | |
| motion: { | |
| transform: { | |
| translateY: options.translateY || 20, | |
| opacity: 0 | |
| } | |
| } | |
| })); | |
| return new MotionSequence(elements, { | |
| sequenceDelay: options.delay || 100, | |
| staggered: true, | |
| autoplay: options.autoplay !== undefined ? options.autoplay : true, | |
| ...options | |
| }); | |
| }, | |
| /** | |
| * Button hover effect with subtle scale and shadow | |
| * @param {string|Element} selector - Element selector | |
| * @param {Object} options - Additional options | |
| * @returns {MotionInteractive} - Motion interactive instance | |
| */ | |
| buttonHover: function(selector, options = {}) { | |
| const elements = document.querySelectorAll(selector); | |
| elements.forEach(element => { | |
| // Store original box-shadow | |
| const originalShadow = window.getComputedStyle(element).boxShadow; | |
| // Create instance | |
| new MotionInteractive(element, { | |
| animationType: 'scale', | |
| duration: 200, | |
| easing: 'ease-out', | |
| behavior: { | |
| type: 'hover' | |
| }, | |
| motion: { | |
| transform: { | |
| scaleX: 1.05, | |
| scaleY: 1.05 | |
| } | |
| }, | |
| ...options, | |
| // Add custom event handlers | |
| onEnter: () => { | |
| element.style.boxShadow = '0 5px 15px rgba(0,0,0,0.2)'; | |
| }, | |
| onLeave: () => { | |
| element.style.boxShadow = originalShadow; | |
| } | |
| }); | |
| }); | |
| // Return the first instance for API access (though multiple were created) | |
| return elements.length > 0 ? | |
| new MotionInteractive(elements[0], { behavior: { type: 'hover' } }) : | |
| null; | |
| } | |
| }; | |
| // Add default styles | |
| (function() { | |
| const styleEl = document.createElement('style'); | |
| styleEl.textContent = ` | |
| /* Motion Interactive Base Styles */ | |
| .motion-interactive { | |
| transition-property: transform, opacity; | |
| will-change: transform, opacity; | |
| } | |
| /* State-based styles */ | |
| .motion-interactive.state-default { | |
| /* Default state styles */ | |
| } | |
| .motion-interactive.state-hover { | |
| /* Hover state styles */ | |
| } | |
| .motion-interactive.state-active { | |
| /* Active state styles */ | |
| } | |
| /* Animation direction classes */ | |
| .motion-interactive.animated-in { | |
| /* Styles for "in" state */ | |
| } | |
| .motion-interactive.animated-out { | |
| /* Styles for "out" state */ | |
| } | |
| `; | |
| document.head.appendChild(styleEl); | |
| })(); | |
| // Export for module usage | |
| if (typeof module !== 'undefined' && module.exports) { | |
| module.exports = { | |
| MotionInteractive, | |
| MotionSequence, | |
| MotionPatterns | |
| }; | |
| } |