diff --git a/docs/GSAP_ANIMATIONS.md b/docs/GSAP_ANIMATIONS.md index 992740c..c8f5507 100644 --- a/docs/GSAP_ANIMATIONS.md +++ b/docs/GSAP_ANIMATIONS.md @@ -1,22 +1,66 @@ -# GSAP Scroll Animations Guide +# GSAP Animation System -GSAP scroll-driven animations for photographs and content. Animations are controlled by scroll position—scrolling down plays forward, scrolling up plays backward. +This project uses GSAP (GreenSock Animation Platform) for scroll-driven and interactive animations, designed for animated storytelling with low friction. + +## Architecture Overview + +The animation system is split into three main parts: + +1. **Shared Effects Library** ([`gsap-effects.js`](../src/assets/scripts/bundle/gsap-effects.js)) + - Reusable animation effects (fadeIn, shake, zoom, etc.) + - Emotional presets (jumpscare, anticipation, dread, etc.) + - Effect composition utilities + +2. **Content Animations** ([`gsap-shortcode-init.js`](../src/assets/scripts/bundle/gsap-shortcode-init.js)) + - Scroll-triggered animations in markdown/blog posts + - Low-friction shortcode syntax for content authors + +3. **UI Component Animations** ([`mix-nav-animations.js`](../src/assets/scripts/bundle/mix-nav-animations.js)) + - Interactive UI animations (hover, click, etc.) + - Component-specific animation logic + +--- + +# For Content Authors + +See [GSAP_USAGE.md](./GSAP_USAGE.md) for the complete usage guide. ## Quick Start +### Emotional Presets (Recommended) + +Animate emotions, not numbers: + ```markdown -{% gsapScrollAnim { - "animationType": "fadeIn", - "scrollStart": "top 80%" -} %} -[{ - "src": "/path/to/image.jpg", - "alt": "Image description", - "caption": "Optional caption" -}] +{% gsapScrollAnim { "emotion": "jumpscare" } %} +[{ "src": "/scary-image.jpg", "alt": "Boo!" }] {% endgsapScrollAnim %} ``` +Available emotions: `jumpscare`, `anticipation`, `dread`, `relief`, `tension`, `excitement` + +### Simple Animations + +```markdown +{% gsapScrollAnim { "animationType": "fadeIn" } %} +[{ "src": "/image.jpg", "alt": "Description" }] +{% endgsapScrollAnim %} +``` + +### Effect Composition + +Combine multiple effects: + +```markdown +{% gsapScrollAnim { "effects": ["fadeIn", "shake", "tremble"] } %} +[{ "src": "/image.jpg", "alt": "Custom combo" }] +{% endgsapScrollAnim %} +``` + +--- + +# For Developers + ## Configuration Options All parameters are optional with sensible defaults: diff --git a/docs/GSAP_USAGE.md b/docs/GSAP_USAGE.md new file mode 100644 index 0000000..9228e1c --- /dev/null +++ b/docs/GSAP_USAGE.md @@ -0,0 +1,309 @@ +# GSAP Animation System + +This project uses GSAP for scroll-driven and UI animations, designed for low-friction animated storytelling. + +## Architecture + +### 1. Shared Effects Library (`gsap-effects.js`) +Contains reusable animation effects and emotional presets. + +### 2. Content Animations (`gsap-shortcode-init.js`) +Handles scroll-triggered animations in markdown/blog posts via shortcodes. + +### 3. UI Component Animations (`mix-nav-animations.js`) +Handles interactive animations for navigation, buttons, and UI elements. + +--- + +## For Content Authors (Markdown/Posts) + +### Basic Usage + +Simple fade-in animation: +```markdown +{% gsapScrollAnim { + "animationType": "fadeIn" +} %} +[{ + "src": "/path/to/image.jpg", + "alt": "Description" +}] +{% endgsapScrollAnim %} +``` + +### Available Animation Types + +- `fadeIn` - Fade in from below +- `fadeInUp` - Fade in from further below +- `fadeInDown` - Fade in from above +- `scaleIn` - Scale up from small +- `slideInLeft` - Slide in from left +- `slideInRight` - Slide in from right +- `parallax` - Parallax scroll effect +- `stagger` - Multiple items animate in sequence +- `zoomIn` - Zoom into image focal point +- `zoomOut` - Zoom out from focal point +- `shake` - Shake back and forth +- `tremble` - Subtle continuous trembling +- `wobble` - Wobble rotation +- `pulse` - Continuous pulsing scale + +### Zoom Animations with Focal Points + +```markdown +{% gsapScrollAnim { + "animationType": "zoomIn", + "focalX": 30, + "focalY": 40, + "startZoom": 1, + "endZoom": 2.5, + "scrub": true +} %} +[{ + "src": "/path/to/high-res-image.jpg", + "alt": "Image to zoom" +}] +{% endgsapScrollAnim %} +``` + +- `focalX` / `focalY`: 0-100 (percentage of image dimensions) +- `startZoom` / `endZoom`: Scale values (1 = normal size) + +--- + +## Emotional Presets (New!) + +Create emotional storytelling without technical details: + +### Jumpscare +```markdown +{% gsapScrollAnim { + "emotion": "jumpscare" +} %} +[{ "src": "/scary-image.jpg", "alt": "Boo!" }] +{% endgsapScrollAnim %} +``` +Sudden appearance + shake + tremble (like an arrow hitting its mark) + +### Anticipation +```markdown +{% gsapScrollAnim { + "emotion": "anticipation", + "scrub": false +} %} +[{ "src": "/windup.jpg", "alt": "Getting ready" }] +{% endgsapScrollAnim %} +``` +Pull back, then spring forward (like winding up before a punch) + +### Dread +```markdown +{% gsapScrollAnim { + "emotion": "dread" +} %} +[{ "src": "/ominous.jpg", "alt": "Something's coming" }] +{% endgsapScrollAnim %} +``` +Slow reveal with unsettling movement + +### Relief +```markdown +{% gsapScrollAnim { + "emotion": "relief" +} %} +[{ "src": "/safe-now.jpg", "alt": "Phew" }] +{% endgsapScrollAnim %} +``` +Gentle fade in with settling motion + +### Tension +```markdown +{% gsapScrollAnim { + "emotion": "tension" +} %} +[{ "src": "/suspense.jpg", "alt": "Building suspense" }] +{% endgsapScrollAnim %} +``` +Slow zoom with subtle shake + +### Excitement +```markdown +{% gsapScrollAnim { + "emotion": "excitement", + "scrub": false +} %} +[{ "src": "/celebration.jpg", "alt": "Yay!" }] +{% endgsapScrollAnim %} +``` +Bouncy entrance with energy + +--- + +## Effect Composition (Advanced) + +Combine multiple effects to create custom emotions: + +```markdown +{% gsapScrollAnim { + "effects": ["fadeIn", "shake", "tremble"] +} %} +[{ "src": "/custom-combo.jpg", "alt": "Custom animation" }] +{% endgsapScrollAnim %} +``` + +Effects apply in sequence and can overlap. + +--- + +## Scroll Control Options + +### `scrub` (default: `true`) +- `true` - Animation tied to scroll position (smooth) +- `false` - Animation plays once when triggered + +### `scrollStart` (default: `"top 80%"`) +When animation begins: +- `"top 80%"` - When element's top hits 80% down viewport +- `"center center"` - When element center hits viewport center +- `"bottom 20%"` - When element bottom hits 20% down viewport + +### `scrollEnd` (default: `"bottom 20%"`) +When animation completes (for scrubbed animations) + +### `pin` (default: `false`) +Pin the element in place during animation: +```markdown +{% gsapScrollAnim { + "animationType": "zoomIn", + "pin": true, + "scrollEnd": "+=500" +} %} +``` + +### `markers` (default: `false`) +Show debug markers (for development): +```markdown +{% gsapScrollAnim { + "animationType": "fadeIn", + "markers": true +} %} +``` + +--- + +## Multiple Images + +```markdown +{% gsapScrollAnim { + "animationType": "stagger" +} %} +[{ + "src": "/image1.jpg", + "alt": "First", + "caption": "Image 1" +}, { + "src": "/image2.jpg", + "alt": "Second", + "caption": "Image 2" +}, { + "src": "/image3.jpg", + "alt": "Third", + "caption": "Image 3" +}] +{% endgsapScrollAnim %} +``` + +--- + +## Tips for Storytelling + +1. **Use emotions first** - `"emotion": "jumpscare"` is easier than combining effects manually +2. **Scrub for slow reveals** - Set `"scrub": true` for scroll-controlled drama +3. **No scrub for punchy moments** - Set `"scrub": false` for quick actions +4. **Pin for focus** - Use `"pin": true` to hold attention on an element +5. **Zoom needs high-res** - Zoom animations automatically request larger image sizes +6. **Compose for unique feels** - Combine effects when presets don't fit: `"effects": ["fadeIn", "wobble"]` + +--- + +## For Developers + +### Adding New Effects + +Edit [`gsap-effects.js`](../src/assets/scripts/bundle/gsap-effects.js): + +```javascript +export const effects = { + myNewEffect: (element, config = {}) => ({ + from: { + opacity: 0, + rotationY: 90 + }, + to: { + opacity: 1, + rotationY: 0, + ease: 'power2.out', + ...config + } + }) +}; +``` + +### Adding Emotional Presets + +```javascript +export const emotions = { + myEmotion: (element, config = {}) => { + const tl = gsap.timeline(); + tl.from(element, { /* initial state */ }) + .to(element, { /* first animation */ }) + .to(element, { /* second animation */ }, '-=0.5'); // overlap + return tl; + } +}; +``` + +### UI Component Animations + +Create component-specific files like [`mix-nav-animations.js`](../src/assets/scripts/bundle/mix-nav-animations.js): + +```javascript +import gsap from 'gsap'; +import { shouldAnimate } from './gsap-effects.js'; + +function initMyComponentAnimations() { + if (!shouldAnimate()) return; + + document.querySelectorAll('.my-element').forEach(el => { + el.addEventListener('mouseenter', () => { + gsap.to(el, { scale: 1.1, duration: 0.2 }); + }); + }); +} + +// Turbo-compatible initialization +document.addEventListener('DOMContentLoaded', initMyComponentAnimations); +if (window.Turbo) { + document.addEventListener('turbo:load', initMyComponentAnimations); +} +``` + +--- + +## Accessibility + +All animations respect `prefers-reduced-motion`. Users with this preference will see static content without animations. + +--- + +## Debugging + +Enable markers to see scroll trigger points: +```markdown +{% gsapScrollAnim { + "animationType": "fadeIn", + "markers": true +} %} +``` + +Check browser console for warnings about missing animation types or configuration errors. diff --git a/src/_data/navigation.js b/src/_data/navigation.js index ebb405a..89d952e 100644 --- a/src/_data/navigation.js +++ b/src/_data/navigation.js @@ -21,6 +21,10 @@ export default { { text: 'Style guide', url: '/styleguide/' + }, + { + text: 'GSAP Animations', + url: '/gsap-animations/' } ] }; diff --git a/src/assets/og-images/gsap-emotional-animation-demo-preview.jpeg b/src/assets/og-images/gsap-emotional-animation-demo-preview.jpeg new file mode 100644 index 0000000..16b710f Binary files /dev/null and b/src/assets/og-images/gsap-emotional-animation-demo-preview.jpeg differ diff --git a/src/assets/scripts/bundle/gsap-effects.js b/src/assets/scripts/bundle/gsap-effects.js new file mode 100644 index 0000000..0c8368d --- /dev/null +++ b/src/assets/scripts/bundle/gsap-effects.js @@ -0,0 +1,494 @@ +/** + * GSAP Effects Library + * Shared animation effects for both shortcodes and UI components + */ +import gsap from 'gsap'; + +/** + * Base animation effects + * Each returns { from, to } objects for GSAP + */ +export const effects = { + fadeIn: (element, config = {}) => ({ + from: { + opacity: 0, + y: 50 + }, + to: { + opacity: 1, + y: 0, + ease: 'power2.out', + ...config + } + }), + + fadeInUp: (element, config = {}) => ({ + from: { + opacity: 0, + y: 100 + }, + to: { + opacity: 1, + y: 0, + ease: 'power2.out', + ...config + } + }), + + fadeInDown: (element, config = {}) => ({ + from: { + opacity: 0, + y: -100 + }, + to: { + opacity: 1, + y: 0, + ease: 'power2.out', + ...config + } + }), + + scaleIn: (element, config = {}) => ({ + from: { + opacity: 0, + scale: 0.8 + }, + to: { + opacity: 1, + scale: 1, + ease: 'back.out(1.7)', + ...config + } + }), + + slideInLeft: (element, config = {}) => ({ + from: { + opacity: 0, + x: -100 + }, + to: { + opacity: 1, + x: 0, + ease: 'power2.out', + ...config + } + }), + + slideInRight: (element, config = {}) => ({ + from: { + opacity: 0, + x: 100 + }, + to: { + opacity: 1, + x: 0, + ease: 'power2.out', + ...config + } + }), + + parallax: (element, config = {}) => ({ + from: { + y: -100 + }, + to: { + y: 100, + ease: 'none', + ...config + } + }), + + stagger: (element, config = {}) => ({ + from: { + opacity: 0, + y: 50 + }, + to: { + opacity: 1, + y: 0, + ease: 'power2.out', + stagger: 0.2, + ...config + } + }), + + shake: (element, config = {}) => ({ + from: {}, + to: { + x: 0, + duration: 0.1, + repeat: 5, + yoyo: true, + ease: 'power1.inOut', + keyframes: { + x: [-5, 5, -4, 4, -3, 3, -2, 2, -1, 0] + }, + ...config + } + }), + + tremble: (element, config = {}) => ({ + from: {}, + to: { + rotation: 0, + duration: 0.05, + repeat: -1, + yoyo: true, + ease: 'none', + keyframes: { + rotation: [-1, 1, -1, 1] + }, + ...config + } + }), + + pulse: (element, config = {}) => ({ + from: {}, + to: { + scale: 1, + duration: 0.8, + repeat: -1, + yoyo: true, + ease: 'power1.inOut', + keyframes: { + scale: [1, 1.05, 1] + }, + ...config + } + }), + + wobble: (element, config = {}) => ({ + from: {}, + to: { + rotation: 0, + duration: 0.3, + repeat: 3, + yoyo: true, + ease: 'power1.inOut', + keyframes: { + rotation: [-5, 5, -3, 3, -1, 0] + }, + ...config + } + }), + + zoomIn: (element, config = {}) => { + const { + focalX = 50, + focalY = 50, + startZoom = 1, + endZoom = 2.5 + } = config || {}; + + return { + from: { + scale: startZoom, + transformOrigin: `${focalX}% ${focalY}%`, + x: 0, + y: 0 + }, + to: { + scale: endZoom, + transformOrigin: `${focalX}% ${focalY}%`, + x: 0, + y: 0, + ease: 'power2.inOut', + ...config + } + }; + }, + + zoomOut: (element, config = {}) => { + const { + focalX = 50, + focalY = 50, + startZoom = 2.5, + endZoom = 1 + } = config || {}; + + return { + from: { + scale: startZoom, + transformOrigin: `${focalX}% ${focalY}%`, + x: 0, + y: 0 + }, + to: { + scale: endZoom, + transformOrigin: `${focalX}% ${focalY}%`, + x: 0, + y: 0, + ease: 'power2.inOut', + ...config + } + }; + }, + + vibrate: (target, config = {}) => { + return { + to: { + x: () => gsap.utils.random(-5, 5), + y: () => gsap.utils.random(-5, 5), + duration: 0.05, + repeat: config.repeat ?? 10, + ease: 'none', + ...config + } + }; + } +}; + +/** + * animation combos, express emtions, tell a story + * **************************************************** + * **************************************************** + * **************************************************** + *****************************************************/ +export const emotions = { +/** + * jumpscare + * **************************************************** + * **************************************************** + * **************************************************** + *****************************************************/ + jumpscare: (element, config = {}) => { + const tl = gsap.timeline(); + tl.from(element, { + scale: 0.5, + opacity: 0, + duration: 0.1, + ease: 'power4.out' + }) + .to(element, { + x: -15, + y: -15, + rotation: 30, + duration: 1, + repeat: 3, + yoyo: true + }) + .to(element, { + rotation: -2, + duration: 0.05, + repeat: -1, + yoyo: true + }); + return tl; + }, + +/** + * anticipation + * **************************************************** + * **************************************************** + * **************************************************** + *****************************************************/ + anticipation: (element, config = {}) => { + const tl = gsap.timeline(); + tl.to(element, { + scale: 0.95, + duration: 0.3, + ease: 'power2.in' + }) + .to(element, { + scale: 1.1, + duration: 0.2, + ease: 'back.out(4)' + }) + .to(element, { + scale: 1, + duration: 0.2, + ease: 'power1.out' + }); + return tl; + }, + +/** + * dread, alledgedly + * **************************************************** + * **************************************************** + * **************************************************** + *****************************************************/ + dread: (element, config = {}) => { + const tl = gsap.timeline(); + tl.from(element, { + opacity: 0, + scale: 1.2, + duration: 2, + ease: 'power1.in' + }) + .to(element, { + rotation: -1, + duration: 0.1, + repeat: -1, + yoyo: true, + ease: 'none' + }, '-=1.5'); + return tl; + }, + +/** + * relief, alledgedly + * **************************************************** + * **************************************************** + * **************************************************** + *****************************************************/ + relief: (element, config = {}) => { + const tl = gsap.timeline(); + tl.from(element, { + opacity: 0, + y: -30, + duration: 1, + ease: 'power2.out' + }) + .to(element, { + y: 5, + duration: 0.4, + ease: 'power1.inOut' + }) + .to(element, { + y: 0, + duration: 0.3, + ease: 'power1.out' + }); + return tl; + }, + +/** + * tension + * **************************************************** + * **************************************************** + * **************************************************** + *****************************************************/ + tension: (element, config = {}) => { + const tl = gsap.timeline(); + tl.from(element, { + scale: 1, + duration: 3, + ease: 'none' + }) + .to(element, { + scale: 1.15, + duration: 3, + ease: 'power1.in' + }, 0) + .to(element, { + x: -1, + duration: 0.1, + repeat: -1, + yoyo: true, + ease: 'none' + }, 1); + return tl; + }, + +/** + * excitement + * **************************************************** + * **************************************************** + * **************************************************** + *****************************************************/ + excitement: (element, config = {}) => { + const tl = gsap.timeline(); + tl.from(element, { + opacity: 0, + scale: 0, + duration: 0.3, + ease: 'back.out(3)' + }) + .to(element, { + y: -10, + duration: 0.3, + ease: 'power2.out' + }) + .to(element, { + y: 0, + duration: 0.3, + ease: 'bounce.out' + }); + return tl; + }, + + /** + * Image Swap: Swap image source at scroll position and vibrate + */ + imageSwap: (element, config = {}) => { + const tl = gsap.timeline({ + scrollTrigger: { + trigger: element, + start: 'center center', + toggleActions: 'play none none none', + once: true, + markers: config.markers || false, + ...config.scrollTrigger + } + }); + + // Get both images from config or data attributes + const img = element.querySelector('img'); + const secondSrc = config.secondImage || element.dataset.secondImage; + + if (!secondSrc || !img) { + console.warn('imageSwap: No second image specified or img element not found', element); + return tl; + } + + // Swap image and vibrate simultaneously + tl.call(() => { + img.src = secondSrc; + // Also update srcset if it exists + if (img.srcset) { + img.srcset = secondSrc; + } + }) + .to(element, { + x: () => gsap.utils.random(-5, 5), + y: () => gsap.utils.random(-5, 5), + duration: 0.05, + repeat: config.vibrateRepeats ?? 15, + ease: 'none' + }, 0); // 0 means start immediately + + return tl; + } +}; + +/** + * Check if user prefers reduced motion + */ +export const shouldAnimate = () => + !window.matchMedia('(prefers-reduced-motion: reduce)').matches; + +/** + * Apply multiple effects to an element + * @param {Element} element - Target element + * @param {Array} effectNames - Array of effect names to apply + * @param {Object} config - Configuration for effects + */ +export const composeEffects = (element, effectNames, config = {}) => { + if (!shouldAnimate()) return gsap.timeline(); + + const tl = gsap.timeline(); + + effectNames.forEach((effectName, index) => { + const effect = effects[effectName]; + if (effect) { + const { from, to } = effect(element, config[effectName] || {}); + + if (index === 0 && Object.keys(from).length > 0) { + tl.from(element, from); + } + if (Object.keys(to).length > 0) { + // If this is a looping effect (repeat: -1), add it at the start + if (to.repeat === -1) { + tl.to(element, to, 0); + } else { + tl.to(element, to); + } + } + } + }); + + return tl; +}; + +export default { effects, emotions, shouldAnimate, composeEffects }; diff --git a/src/assets/scripts/bundle/gsap-shortcode-init.js b/src/assets/scripts/bundle/gsap-shortcode-init.js index ca64145..843a755 100644 --- a/src/assets/scripts/bundle/gsap-shortcode-init.js +++ b/src/assets/scripts/bundle/gsap-shortcode-init.js @@ -6,6 +6,7 @@ import gsap from 'gsap'; import ScrollTrigger from 'gsap/ScrollTrigger'; +import { effects, emotions, shouldAnimate, composeEffects } from './gsap-effects.js'; // Register GSAP plugins gsap.registerPlugin(ScrollTrigger); @@ -14,7 +15,7 @@ gsap.registerPlugin(ScrollTrigger); const activeContexts = new Map(); /** - * Animation presets + * Animation presets (use effects from gsap-effects.js, but kept for backward compatibility) * Each returns GSAP animation properties for the given element(s) */ const animations = { @@ -177,9 +178,7 @@ const animations = { */ function initGsapAnimations() { // Check if reduced motion is preferred - const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; - - if (prefersReducedMotion) { + if (!shouldAnimate()) { // Skip animations if user prefers reduced motion console.log('GSAP animations disabled: prefers-reduced-motion'); return; @@ -192,8 +191,12 @@ function initGsapAnimations() { try { // Parse configuration from data attribute const config = JSON.parse(container.dataset.gsapScrollAnim); + + // Extract configuration with defaults const { - animationType = 'fadeIn', + animationType, + emotion, + effects: effectsList, scrollStart = 'top 80%', scrollEnd = 'bottom 20%', scrub = true, @@ -201,51 +204,105 @@ function initGsapAnimations() { markers = false } = config; - // Get animation preset - const animationPreset = animations[animationType]; - if (!animationPreset) { - console.warn(`Unknown animation type: ${animationType}`); - return; - } - // Find all animated items within container const items = container.querySelectorAll('.gsap-item'); if (items.length === 0) return; // Create GSAP context for this container const ctx = gsap.context(() => { - // For zoom animations, apply to images directly - const targetElements = (animationType === 'zoomIn' || animationType === 'zoomOut') - ? container.querySelectorAll('.gsap-image') - : items; + let timeline; - const anim = animationPreset(targetElements, config); - - // Create timeline with ScrollTrigger - const timeline = gsap.timeline({ - scrollTrigger: { - trigger: container, - start: scrollStart, - end: scrollEnd, - scrub: scrub ? 1 : false, // Smooth scrubbing - markers: markers, // Show markers for debugging - pin: pin, - toggleActions: scrub ? undefined : 'play reverse play reverse', - // Callbacks for debugging - onEnter: () => container.dataset.gsapActive = 'true', - onLeave: () => container.dataset.gsapActive = 'false', - onEnterBack: () => container.dataset.gsapActive = 'true', - onLeaveBack: () => container.dataset.gsapActive = 'false' + // Handle emotional presets (take priority) + if (emotion && emotions[emotion]) { + timeline = gsap.timeline({ + scrollTrigger: { + trigger: container, + start: scrollStart, + end: scrollEnd, + scrub: scrub ? 1 : false, + markers: markers, + pin: pin, + toggleActions: scrub ? undefined : 'play reverse play reverse', + onEnter: () => container.dataset.gsapActive = 'true', + onLeave: () => container.dataset.gsapActive = 'false', + onEnterBack: () => container.dataset.gsapActive = 'true', + onLeaveBack: () => container.dataset.gsapActive = 'false' + } + }); + + // Apply emotional preset to each item + items.forEach(item => { + const emotionTl = emotions[emotion](item, config); + timeline.add(emotionTl, 0); // Add at start + }); + } + // Handle effect composition (multiple effects) + else if (effectsList && Array.isArray(effectsList)) { + timeline = gsap.timeline({ + scrollTrigger: { + trigger: container, + start: scrollStart, + end: scrollEnd, + scrub: scrub ? 1 : false, + markers: markers, + pin: pin, + toggleActions: scrub ? undefined : 'play reverse play reverse', + onEnter: () => container.dataset.gsapActive = 'true', + onLeave: () => container.dataset.gsapActive = 'false', + onEnterBack: () => container.dataset.gsapActive = 'true', + onLeaveBack: () => container.dataset.gsapActive = 'false' + } + }); + + // Apply each effect in sequence + items.forEach(item => { + const composedTl = composeEffects(item, effectsList, config); + timeline.add(composedTl, 0); + }); + } + // Handle single animation type (original behavior) + else if (animationType) { + const animationPreset = animations[animationType]; + if (!animationPreset) { + console.warn(`Unknown animation type: ${animationType}`); + return; } - }); - - // Apply animation - if (anim.from && anim.to) { - timeline.fromTo(items, anim.from, anim.to); - } else if (anim.from) { - timeline.from(items, anim.from); - } else if (anim.to) { - timeline.to(items, anim.to); + + // For zoom animations, apply to images directly + const targetElements = (animationType === 'zoomIn' || animationType === 'zoomOut') + ? container.querySelectorAll('.gsap-image') + : items; + + const anim = animationPreset(targetElements, config); + + // Create timeline with ScrollTrigger + timeline = gsap.timeline({ + scrollTrigger: { + trigger: container, + start: scrollStart, + end: scrollEnd, + scrub: scrub ? 1 : false, + markers: markers, + pin: pin, + toggleActions: scrub ? undefined : 'play reverse play reverse', + onEnter: () => container.dataset.gsapActive = 'true', + onLeave: () => container.dataset.gsapActive = 'false', + onEnterBack: () => container.dataset.gsapActive = 'true', + onLeaveBack: () => container.dataset.gsapActive = 'false' + } + }); + + // Apply animation + if (anim.from && anim.to) { + timeline.fromTo(targetElements, anim.from, anim.to); + } else if (anim.from) { + timeline.from(targetElements, anim.from); + } else if (anim.to) { + timeline.to(targetElements, anim.to); + } + } else { + console.warn('No animation type, emotion, or effects specified', config); + return; } }, container); diff --git a/src/assets/scripts/bundle/mix-nav-animations.js b/src/assets/scripts/bundle/mix-nav-animations.js new file mode 100644 index 0000000..3ea1f4a --- /dev/null +++ b/src/assets/scripts/bundle/mix-nav-animations.js @@ -0,0 +1,152 @@ +/** + * Mix Navigation Animations + * GSAP animations for mix track navigation UI elements + */ +import gsap from 'gsap'; +import { shouldAnimate } from './gsap-effects.js'; + +/** + * Initialize mix navigation animations + */ +function initMixNavAnimations() { + if (!shouldAnimate()) return; + + // Animate track navigation buttons on hover + const trackNavButtons = document.querySelectorAll('[data-track-nav]'); + + trackNavButtons.forEach(btn => { + const direction = btn.closest('[data-direction]')?.dataset.direction; + + // Set up hover animation + btn.addEventListener('mouseenter', () => { + gsap.to(btn, { + scale: 1.05, + duration: 0.2, + ease: 'back.out(2)' + }); + + // Slight movement based on direction + if (direction === 'prev') { + gsap.to(btn, { + x: -3, + duration: 0.2, + ease: 'power2.out' + }); + } else if (direction === 'next') { + gsap.to(btn, { + x: 3, + duration: 0.2, + ease: 'power2.out' + }); + } + }); + + btn.addEventListener('mouseleave', () => { + gsap.to(btn, { + scale: 1, + x: 0, + duration: 0.2, + ease: 'power2.inOut' + }); + }); + + // Click animation + btn.addEventListener('click', (e) => { + gsap.to(btn, { + scale: 0.95, + duration: 0.1, + yoyo: true, + repeat: 1, + ease: 'power2.inOut' + }); + }); + }); + + // Animate track list background on hover + const trackListBg = document.querySelectorAll('.track-list-bg'); + + trackListBg.forEach(bg => { + const btn = bg.querySelector('.button'); + if (!btn) return; + + btn.addEventListener('mouseenter', () => { + gsap.to(bg, { + scale: 1.03, + duration: 0.2, + ease: 'back.out(2)' + }); + }); + + btn.addEventListener('mouseleave', () => { + gsap.to(bg, { + scale: 1, + duration: 0.2, + ease: 'power2.inOut' + }); + }); + }); + + // Animate track nav backgrounds on hover + const trackNavBg = document.querySelectorAll('.track-nav-bg'); + + trackNavBg.forEach(bg => { + const btn = bg.querySelector('[data-track-nav]'); + if (!btn) return; + + const direction = bg.dataset.direction; + + btn.addEventListener('mouseenter', () => { + gsap.to(bg, { + scale: 1.02, + duration: 0.2, + ease: 'power2.out' + }); + + // Slight rotation based on direction + if (direction === 'prev') { + gsap.to(bg, { + rotation: -1, + duration: 0.2, + ease: 'power2.out' + }); + } else if (direction === 'next') { + gsap.to(bg, { + rotation: 1, + duration: 0.2, + ease: 'power2.out' + }); + } + }); + + btn.addEventListener('mouseleave', () => { + gsap.to(bg, { + scale: 1, + rotation: 0, + duration: 0.2, + ease: 'power2.inOut' + }); + }); + }); +} + +/** + * Cleanup function for animations + */ +function cleanupMixNavAnimations() { + // Kill all active tweens on nav elements + gsap.killTweensOf('[data-track-nav]'); + gsap.killTweensOf('.track-list-bg'); + gsap.killTweensOf('.track-nav-bg'); +} + +// Initialize on page load +document.addEventListener('DOMContentLoaded', initMixNavAnimations); + +// Turbo Drive compatibility +if (window.Turbo) { + document.addEventListener('turbo:before-render', cleanupMixNavAnimations); + document.addEventListener('turbo:render', initMixNavAnimations); + document.addEventListener('turbo:load', initMixNavAnimations); +} + +export { initMixNavAnimations, cleanupMixNavAnimations }; diff --git a/src/pages/gsap-animations.md b/src/pages/gsap-animations.md new file mode 100644 index 0000000..9b046ab --- /dev/null +++ b/src/pages/gsap-animations.md @@ -0,0 +1,352 @@ +--- +title: GSAP Animation Reference +description: Visual guide to all available GSAP animations and how to use them +layout: post +permalink: 'docs/animations/index.html' +--- + +# GSAP Animation Reference + +A visual reference for all available scroll-driven animations. Scroll down to see each effect in action! + +## Quick Start + +```markdown +{% raw %}{% gsapScrollAnim { "animationType": "fadeIn" } %} +[{ "src": "/path/to/image.jpg", "alt": "My image" }] +{% endgsapScrollAnim %}{% endraw %} +``` + +--- + +## Basic Effects + +### Fade In +Gentle fade and slide up entrance. + +{% gsapScrollAnim { "animationType": "fadeIn", "scrollStart": "top 80%", "scrollEnd": "bottom 20%", "scrub": true } %} +[{ "src": "/pages/projects/mixes/tomorrowsbacon/gorillaz-cracker-island.jpg", "alt": "Fade In Demo" }] +{% endgsapScrollAnim %} + +```markdown +{% raw %}{% gsapScrollAnim { "animationType": "fadeIn" } %} +[{ "src": "/image.jpg", "alt": "Demo" }] +{% endgsapScrollAnim %}{% endraw %} +``` + +### Fade In Up +Strong upward entrance. + +{% gsapScrollAnim { "animationType": "fadeInUp", "scrollStart": "top 80%", "scrollEnd": "bottom 20%", "scrub": true } %} +[{ "src": "/pages/projects/mixes/tomorrowsbacon/radiohead-ok-computer.jpg", "alt": "Fade In Up Demo" }] +{% endgsapScrollAnim %} + +```markdown +{% raw %}{% gsapScrollAnim { "animationType": "fadeInUp" } %} +[{ "src": "/image.jpg", "alt": "Demo" }] +{% endgsapScrollAnim %}{% endraw %} +``` + +### Fade In Down +Drops in from above. + +{% gsapScrollAnim { "animationType": "fadeInDown", "scrollStart": "top 80%", "scrollEnd": "bottom 20%", "scrub": true } %} +[{ "src": "/pages/projects/mixes/tomorrowsbacon/the-clash-london-calling.jpeg", "alt": "Fade In Down Demo" }] +{% endgsapScrollAnim %} + +```markdown +{% raw %}{% gsapScrollAnim { "animationType": "fadeInDown" } %} +[{ "src": "/image.jpg", "alt": "Demo" }] +{% endgsapScrollAnim %}{% endraw %} +``` + +### Scale In +Grows from center with bounce. + +{% gsapScrollAnim { "animationType": "scaleIn", "scrollStart": "top 80%", "scrollEnd": "bottom 20%", "scrub": false } %} +[{ "src": "/pages/projects/mixes/tomorrowsbacon/james-whiplash.jpg", "alt": "Scale In Demo" }] +{% endgsapScrollAnim %} + +```markdown +{% raw %}{% gsapScrollAnim { "animationType": "scaleIn", "scrub": false } %} +[{ "src": "/image.jpg", "alt": "Demo" }] +{% endgsapScrollAnim %}{% endraw %} +``` + +### Slide In Left + +{% gsapScrollAnim { "animationType": "slideInLeft", "scrollStart": "top 80%", "scrollEnd": "bottom 20%", "scrub": true } %} +[{ "src": "/pages/projects/mixes/tomorrowsbacon/rtj-rtj4.jpg", "alt": "Slide In Left Demo" }] +{% endgsapScrollAnim %} + +### Slide In Right + +{% gsapScrollAnim { "animationType": "slideInRight", "scrollStart": "top 80%", "scrollEnd": "bottom 20%", "scrub": true } %} +[{ "src": "/pages/projects/mixes/tomorrowsbacon/ride-nowhere.jpg", "alt": "Slide In Right Demo" }] +{% endgsapScrollAnim %} + +--- + +## Zoom Effects + +### Zoom In +Slow zoom into the image as you scroll. + +{% gsapScrollAnim { "animationType": "zoomIn", "scrollStart": "top 80%", "scrollEnd": "middle middle", "scrub": true } %} +[{ "src": "/pages/projects/mixes/tomorrowsbacon/modest-mouse-we-were-dead.jpg", "alt": "Zoom In Demo" }] +{% endgsapScrollAnim %} + +```markdown +{% raw %}{% gsapScrollAnim { + "animationType": "zoomIn", + "focalX": 50, + "focalY": 50, + "startZoom": 1, + "endZoom": 2.5, + "scrub": true +} %} +[{ "src": "/image.jpg", "alt": "Demo" }] +{% endgsapScrollAnim %}{% endraw %} +``` + +### Zoom Out +Reverse zoom effect. + +{% gsapScrollAnim { "animationType": "zoomOut", "scrollStart": "top 80%", "scrollEnd": "bottom 20%", "scrub": true } %} +[{ "src": "/pages/projects/mixes/tomorrowsbacon/morphine-yes.jpg", "alt": "Zoom Out Demo" }] +{% endgsapScrollAnim %} + +--- + +## Emotional Presets + +These animations tell stories, not just numbers. + +### Jumpscare 💥 +Sudden, intense appearance like an arrow hitting its mark. + +{% gsapScrollAnim { "emotion": "jumpscare", "scrub": false } %} +[{ "src": "/pages/projects/mixes/tomorrowsbacon/james-whiplash.jpg", "alt": "Jumpscare Demo" }] +{% endgsapScrollAnim %} + +```markdown +{% raw %}{% gsapScrollAnim { "emotion": "jumpscare", "scrub": false } %} +[{ "src": "/image.jpg", "alt": "Demo" }] +{% endgsapScrollAnim %}{% endraw %} +``` + +### Anticipation ⏳ +Wind up before the punch - builds tension. + +{% gsapScrollAnim { "emotion": "anticipation", "scrub": false } %} +[{ "src": "/pages/projects/mixes/tomorrowsbacon/gorillaz-cracker-island.jpg", "alt": "Anticipation Demo" }] +{% endgsapScrollAnim %} + +```markdown +{% raw %}{% gsapScrollAnim { "emotion": "anticipation", "scrub": false } %} +[{ "src": "/image.jpg", "alt": "Demo" }] +{% endgsapScrollAnim %}{% endraw %} +``` + +### Dread 😰 +Something ominous slowly approaches. + +{% gsapScrollAnim { "emotion": "dread", "scrub": true } %} +[{ "src": "/pages/projects/mixes/tomorrowsbacon/radiohead-ok-computer.jpg", "alt": "Dread Demo" }] +{% endgsapScrollAnim %} + +```markdown +{% raw %}{% gsapScrollAnim { "emotion": "dread", "scrub": true } %} +[{ "src": "/image.jpg", "alt": "Demo" }] +{% endgsapScrollAnim %}{% endraw %} +``` + +### Relief 😌 +Everything's going to be okay - gentle, calming. + +{% gsapScrollAnim { "emotion": "relief", "scrub": false } %} +[{ "src": "/pages/projects/mixes/tomorrowsbacon/the-clash-london-calling.jpeg", "alt": "Relief Demo" }] +{% endgsapScrollAnim %} + +```markdown +{% raw %}{% gsapScrollAnim { "emotion": "relief", "scrub": false } %} +[{ "src": "/image.jpg", "alt": "Demo" }] +{% endgsapScrollAnim %}{% endraw %} +``` + +### Tension 😬 +Slow zoom with subtle shake - something's wrong. + +{% gsapScrollAnim { "emotion": "tension", "scrub": true } %} +[{ "src": "/pages/projects/mixes/tomorrowsbacon/bjork-all-is-full-of-love.jpg", "alt": "Tension Demo" }] +{% endgsapScrollAnim %} + +### Excitement 🎉 +Bouncy, energetic entrance. + +{% gsapScrollAnim { "emotion": "excitement", "scrub": false } %} +[{ "src": "/pages/projects/mixes/tomorrowsbacon/parquet-courts-wide-awake.png", "alt": "Excitement Demo" }] +{% endgsapScrollAnim %} + +--- + +## Advanced: Image Swap 🔄 + +### Emotion Preset (Easier) +Swap images when scrolled halfway past, with vibration effect. + +{% gsapScrollAnim { + "emotion": "imageSwap", + "secondImage": "/pages/projects/mixes/tomorrowsbacon/radiohead-ok-computer.jpg", + "vibrateRepeats": 20 +} %} +[{ + "src": "/pages/projects/mixes/tomorrowsbacon/gorillaz-cracker-island.jpg", + "alt": "Image Swap Emotion Demo", + "data-second-image": "/pages/projects/mixes/tomorrowsbacon/radiohead-ok-computer.jpg" +}] +{% endgsapScrollAnim %} + +```markdown +{% raw %}{% gsapScrollAnim { + "emotion": "imageSwap", + "secondImage": "/path/to/second-image.jpg", + "vibrateRepeats": 20 +} %} +[{ + "src": "/path/to/first-image.jpg", + "alt": "Demo", + "data-second-image": "/path/to/second-image.jpg" +}] +{% endgsapScrollAnim %}{% endraw %} +``` + +### Effects Composition (More Control) +Combine vibrate with other effects for custom animations. + +{% gsapScrollAnim { + "effects": ["fadeIn", "vibrate"], + "scrollStart": "top 80%", + "scrollEnd": "bottom 20%", + "scrub": false +} %} +[{ "src": "/pages/projects/mixes/tomorrowsbacon/operation-ivy-energy.jpg", "alt": "Vibrate Effect Demo" }] +{% endgsapScrollAnim %} + +```markdown +{% raw %}{% gsapScrollAnim { + "effects": ["fadeIn", "vibrate"], + "scrub": false +} %} +[{ "src": "/image.jpg", "alt": "Demo" }] +{% endgsapScrollAnim %}{% endraw %} +``` + +--- + +## Effect Composition + +Combine multiple effects to create unique animations. + +### Fade + Shake + +{% gsapScrollAnim { + "effects": ["fadeIn", "shake"], + "scrollStart": "top 80%", + "scrollEnd": "bottom 20%", + "scrub": false +} %} +[{ "src": "/pages/projects/mixes/tomorrowsbacon/ramones-mania.jpg", "alt": "Fade + Shake Demo" }] +{% endgsapScrollAnim %} + +```markdown +{% raw %}{% gsapScrollAnim { + "effects": ["fadeIn", "shake"], + "scrub": false +} %} +[{ "src": "/image.jpg", "alt": "Demo" }] +{% endgsapScrollAnim %}{% endraw %} +``` + +### Scale + Wobble + Pulse + +{% gsapScrollAnim { + "effects": ["scaleIn", "wobble", "pulse"], + "scrollStart": "top 80%", + "scrollEnd": "bottom 20%", + "scrub": false +} %} +[{ "src": "/pages/projects/mixes/tomorrowsbacon/van-halen-van-halen.jpg", "alt": "Triple Combo Demo" }] +{% endgsapScrollAnim %} + +```markdown +{% raw %}{% gsapScrollAnim { + "effects": ["scaleIn", "wobble", "pulse"], + "scrub": false +} %} +[{ "src": "/image.jpg", "alt": "Demo" }] +{% endgsapScrollAnim %}{% endraw %} +``` + +--- + +## Configuration Options + +### Scrub +- `"scrub": true` - Animation progress tied to scroll position (smooth) +- `"scrub": false` - Animation plays once when triggered + +### Scroll Triggers +- `"scrollStart": "top 80%"` - When to start (element position + viewport position) +- `"scrollEnd": "bottom 20%"` - When to end +- Common values: `"top center"`, `"center center"`, `"bottom top"` + +### Pin +- `"pin": true` - Pin element in place while animating +- `"pin": false` - Element scrolls normally + +### Markers +- `"markers": true` - Show debug markers (for development) +- `"markers": false` - Hide markers (for production) + +--- + +## Tips & Best Practices + +1. **Use emotions for storytelling** - They're designed to evoke feelings +2. **Scrub for cinematic effects** - Ties animation to scroll for precise control +3. **No scrub for surprise** - Let animations play independently +4. **Compose effects carefully** - Too many can be overwhelming +5. **Test on mobile** - Animations may need adjustment for smaller screens +6. **Respect reduced motion** - All animations respect `prefers-reduced-motion` setting + +--- + +## All Available Effects + +**Base Effects:** +- `fadeIn`, `fadeInUp`, `fadeInDown` +- `scaleIn` +- `slideInLeft`, `slideInRight` +- `parallax` +- `stagger` +- `shake`, `tremble`, `pulse`, `wobble` +- `zoomIn`, `zoomOut` +- `vibrate` + +**Emotional Presets:** +- `jumpscare` - Sudden impact +- `anticipation` - Building tension +- `dread` - Ominous approach +- `relief` - Calming resolution +- `tension` - Slow building stress +- `excitement` - Bouncy energy +- `imageSwap` - Image replacement with vibration + +**Composition:** +- Combine any effects using `"effects": ["effect1", "effect2"]` +- Effects can be layered for unique results + +--- + +

View this page's source code to see exactly how each animation is configured.

diff --git a/src/posts/2025/testing/emotional-animations.md b/src/posts/2025/testing/emotional-animations.md new file mode 100644 index 0000000..03975a5 --- /dev/null +++ b/src/posts/2025/testing/emotional-animations.md @@ -0,0 +1,123 @@ +--- +title: 'GSAP Emotional Animation Demo' +description: "Demonstrating the new emotional animation presets" +date: 2026-01-05 +tags: ['test', 'gsap'] +--- + +## Emotional Presets Demo + +Testing the new emotional animation system that lets you animate feelings, not just numbers. + +### Jumpscare + +Like an arrow hitting its mark - sudden appearance with impact: + +{% gsapScrollAnim { + "emotion": "jumpscare", + "scrub": true +} %} +[{ + "src": "/pages/projects/mixes/tomorrowsbacon/james-whiplash.jpg", + "alt": "Sudden impact!" +}] +{% endgsapScrollAnim %} + +### Anticipation + +Wind up before the punch: + +{% gsapScrollAnim { + "emotion": "anticipation", + "scrub": false, + "scrollStart": "top 75%" +} %} +[{ + "src": "/pages/projects/mixes/tomorrowsbacon/gorillaz-cracker-island.jpg", + "alt": "Building up..." +}] +{% endgsapScrollAnim %} + +### Dread + +Something ominous approaches: + +{% gsapScrollAnim { + "emotion": "dread", + "scrub": true +} %} +[{ + "src": "/pages/projects/mixes/tomorrowsbacon/radiohead-ok-computer.jpg", + "alt": "Unsettling feeling" +}] +{% endgsapScrollAnim %} + +### Relief + +Everything's going to be okay: + +{% gsapScrollAnim { + "emotion": "relief", + "scrub": false +} %} +[{ + "src": "/pages/projects/mixes/tomorrowsbacon/the-clash-london-calling.jpeg", + "alt": "Phew, safe now" +}] +{% endgsapScrollAnim %} + +### Excitement + +Bouncy, energetic entrance: + +{% gsapScrollAnim { + "emotion": "excitement", + "scrub": false +} %} +[{ + "src": "/pages/projects/mixes/tomorrowsbacon/james-whiplash.jpg", + "alt": "So exciting!" +}] +{% endgsapScrollAnim %} + +## Effect Composition + +Combining multiple effects to create custom emotions: + +{% gsapScrollAnim { + "effects": ["fadeIn", "shake"], + "scrub": true +} %} +[{ + "src": "/pages/projects/mixes/tomorrowsbacon/gorillaz-cracker-island.jpg", + "alt": "Fade in + shake combo" +}] +{% endgsapScrollAnim %} + +### Triple Combo + +{% gsapScrollAnim { + "effects": ["scaleIn", "wobble", "pulse"], + "scrub": false +} %} +[{ + "src": "/pages/projects/mixes/tomorrowsbacon/radiohead-ok-computer.jpg", + "alt": "Scale + wobble + pulse" +}] +{% endgsapScrollAnim %} + +--- + +## Simple Animations Still Work + +The original simple syntax still works perfectly: + +{% gsapScrollAnim { + "animationType": "fadeIn", + "scrub": true +} %} +[{ + "src": "/pages/projects/mixes/tomorrowsbacon/the-clash-london-calling.jpeg", + "alt": "Classic fade in" +}] +{% endgsapScrollAnim %}