MorphGuard / static /js /motion_interactive.js
juanquy's picture
Initial clean commit of modular MorphGuard
2978bba
Raw
History Blame Contribute Delete
35.4 kB
/**
* 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
};
}