Rough pass at an effects library

This commit is contained in:
Ben Aultowski 2026-01-06 17:38:53 -05:00
parent 9d3778f74e
commit 821e47a019
9 changed files with 1588 additions and 53 deletions

View file

@ -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:

309
docs/GSAP_USAGE.md Normal file
View file

@ -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.

View file

@ -21,6 +21,10 @@ export default {
{
text: 'Style guide',
url: '/styleguide/'
},
{
text: 'GSAP Animations',
url: '/gsap-animations/'
}
]
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View file

@ -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 };

View file

@ -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,19 +204,70 @@ 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(() => {
let timeline;
// 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;
}
// For zoom animations, apply to images directly
const targetElements = (animationType === 'zoomIn' || animationType === 'zoomOut')
? container.querySelectorAll('.gsap-image')
@ -222,16 +276,15 @@ function initGsapAnimations() {
const anim = animationPreset(targetElements, config);
// Create timeline with ScrollTrigger
const timeline = gsap.timeline({
timeline = gsap.timeline({
scrollTrigger: {
trigger: container,
start: scrollStart,
end: scrollEnd,
scrub: scrub ? 1 : false, // Smooth scrubbing
markers: markers, // Show markers for debugging
scrub: scrub ? 1 : false,
markers: markers,
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',
@ -241,11 +294,15 @@ function initGsapAnimations() {
// Apply animation
if (anim.from && anim.to) {
timeline.fromTo(items, anim.from, anim.to);
timeline.fromTo(targetElements, anim.from, anim.to);
} else if (anim.from) {
timeline.from(items, anim.from);
timeline.from(targetElements, anim.from);
} else if (anim.to) {
timeline.to(items, anim.to);
timeline.to(targetElements, anim.to);
}
} else {
console.warn('No animation type, emotion, or effects specified', config);
return;
}
}, container);

View file

@ -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 };

View file

@ -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
---
<p class="text-step-min-1"><a href="https://git.hypnagaga.com/wires/hypnagaga/src/branch/main/src/pages/gsap-animations.md">View this page's source code</a> to see exactly how each animation is configured.</p>

View file

@ -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 %}