Scaffolded GSAP animation shortcode system.

This commit is contained in:
Ben Aultowski 2026-01-05 14:06:21 -05:00
parent eba69326f0
commit 1775310408
11 changed files with 676 additions and 2 deletions

216
docs/GSAP_ANIMATIONS.md Normal file
View file

@ -0,0 +1,216 @@
# GSAP Scroll Animations Guide
GSAP scroll-driven animations for photographs and content. Animations are controlled by scroll position—scrolling down plays forward, scrolling up plays backward.
## Quick Start
```markdown
{% gsapScrollAnim {
"animationType": "fadeIn",
"scrollStart": "top 80%"
} %}
[{
"src": "/path/to/image.jpg",
"alt": "Image description",
"caption": "Optional caption"
}]
{% endgsapScrollAnim %}
```
## Configuration Options
All parameters are optional with sensible defaults:
```javascript
{
"animationType": "fadeIn", // Animation preset (see below)
"scrollStart": "top 80%", // When animation starts
"scrollEnd": "bottom 20%", // When animation completes
"scrub": true, // Tied to scroll position (bidirectional)
"containerClass": "gsap-container", // CSS class for wrapper
"pin": false, // Pin element during animation
"markers": false // Show debug markers (dev only)
}
```
### Animation Presets
- **fadeIn** - Fade in from below (opacity + translate Y)
- **fadeInUp** - Fade in from further below
- **fadeInDown** - Fade in from above
- **scaleIn** - Scale up with fade in (with bounce effect)
- **slideInLeft** - Slide in from left side
- **slideInRight** - Slide in from right side
- **parallax** - Subtle vertical parallax effect
- **stagger** - Sequential animation for multiple items
## Image Format
Images are defined as JSON array inside the paired shortcode:
```json
[{
"src": "/path/to/image.jpg",
"alt": "Required alt text",
"caption": "Optional caption text"
}, {
"src": "/path/to/image2.jpg",
"alt": "Second image",
"caption": "Another caption"
}]
```
Images are automatically optimized via Eleventy Image plugin (WebP/JPEG, responsive sizes).
## Examples
### Single Image with Fade In
```markdown
{% gsapScrollAnim {
"animationType": "fadeIn"
} %}
[{
"src": "/images/photo.jpg",
"alt": "Description"
}]
{% endgsapScrollAnim %}
```
### Multiple Images with Stagger
```markdown
{% gsapScrollAnim {
"animationType": "stagger",
"scrollStart": "top 70%"
} %}
[{
"src": "/images/photo1.jpg",
"alt": "First photo",
"caption": "Photo 1"
}, {
"src": "/images/photo2.jpg",
"alt": "Second photo",
"caption": "Photo 2"
}]
{% endgsapScrollAnim %}
```
### Parallax Effect
```markdown
{% gsapScrollAnim {
"animationType": "parallax",
"scrub": true
} %}
[{
"src": "/images/background.jpg",
"alt": "Background scene"
}]
{% endgsapScrollAnim %}
```
### Custom Container Class
```markdown
{% gsapScrollAnim {
"animationType": "scaleIn",
"containerClass": "gsap-container featured-image"
} %}
[{
"src": "/images/hero.jpg",
"alt": "Hero image"
}]
{% endgsapScrollAnim %}
```
## ScrollTrigger Settings
### scrollStart / scrollEnd
Control when the animation begins and ends relative to viewport:
- `"top bottom"` - Element's top enters viewport bottom
- `"top 80%"` - Element's top at 80% viewport height
- `"center center"` - Element center aligns with viewport center
- `"bottom top"` - Element bottom exits viewport top
Default: `scrollStart: "top 80%"`, `scrollEnd: "bottom 20%"`
### scrub
When `true` (default), animation progress is tied directly to scroll position, enabling bidirectional playback.
When `false`, animation plays once when scroll position crosses `scrollStart` threshold.
## CSS Customization
Default wrapper class: `.gsap-container`
Generated structure:
```html
<div class="gsap-container" data-gsap-scroll-anim='{...}'>
<div class="gsap-item">
<figure class="gsap-image-wrapper">
<picture>
<img class="gsap-image" src="..." alt="...">
</picture>
<figcaption>...</figcaption>
</figure>
</div>
</div>
```
Add custom styles in your CSS:
```css
.gsap-container.featured-image {
max-width: 1200px;
margin: 0 auto;
}
.gsap-item {
margin-bottom: 2rem;
}
```
## Accessibility
- Respects `prefers-reduced-motion` - animations disabled automatically
- All images require `alt` text
- ScrollTrigger callbacks maintain proper ARIA states
## Turbo Drive Compatibility
Animations are automatically cleaned up and re-initialized on Turbo navigation:
- Contexts reverted before page change
- ScrollTrigger instances killed
- Re-initialized after new page loads
## Debugging
Enable debug markers to visualize scroll trigger points:
```markdown
{% gsapScrollAnim {
"animationType": "fadeIn",
"markers": true
} %}
[...]
{% endgsapScrollAnim %}
```
This shows colored markers in viewport indicating trigger start/end positions.
## Performance
- Animations use GPU-accelerated properties (`transform`, `opacity`)
- `will-change` applied for optimization
- ScrollTrigger efficiently batches calculations
- Contexts properly cleaned up on navigation
## Files Modified
- **Shortcode**: `src/_config/shortcodes/gsap.js`
- **Initializer**: `src/assets/scripts/bundle/gsap-shortcode-init.js`
- **CSS**: `src/assets/css/global/utilities/gsap-animations.css`
- **Config**: Registered in `eleventy.config.js` as paired shortcode

View file

@ -97,6 +97,7 @@ export default async function (eleventyConfig) {
eleventyConfig.addShortcode('image', shortcodes.imageShortcode); eleventyConfig.addShortcode('image', shortcodes.imageShortcode);
eleventyConfig.addShortcode('imageKeys', shortcodes.imageKeysShortcode); eleventyConfig.addShortcode('imageKeys', shortcodes.imageKeysShortcode);
eleventyConfig.addShortcode('animateText', shortcodes.animateText); eleventyConfig.addShortcode('animateText', shortcodes.animateText);
eleventyConfig.addPairedShortcode('gsapScrollAnim', shortcodes.gsapScrollAnim);
eleventyConfig.addShortcode('year', () => `${new Date().getFullYear()}`); eleventyConfig.addShortcode('year', () => `${new Date().getFullYear()}`);

7
package-lock.json generated
View file

@ -18,6 +18,7 @@
"@11ty/eleventy-plugin-webc": "^0.11.2", "@11ty/eleventy-plugin-webc": "^0.11.2",
"@11ty/is-land": "^4.0.1", "@11ty/is-land": "^4.0.1",
"@hotwired/turbo": "^8.0.20", "@hotwired/turbo": "^8.0.20",
"gsap": "^3.14.2",
"lite-youtube-embed": "^0.3.4", "lite-youtube-embed": "^0.3.4",
"tailwindcss": "^3.4.17" "tailwindcss": "^3.4.17"
}, },
@ -4230,6 +4231,12 @@
"js-yaml": "bin/js-yaml.js" "js-yaml": "bin/js-yaml.js"
} }
}, },
"node_modules/gsap": {
"version": "3.14.2",
"resolved": "https://registry.npmjs.org/gsap/-/gsap-3.14.2.tgz",
"integrity": "sha512-P8/mMxVLU7o4+55+1TCnQrPmgjPKnwkzkXOK1asnR9Jg2lna4tEY5qBJjMmAaOBDDZWtlRjBXjLa0w53G/uBLA==",
"license": "Standard 'no charge' license: https://gsap.com/standard-license."
},
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",

View file

@ -39,6 +39,7 @@
"@11ty/eleventy-plugin-webc": "^0.11.2", "@11ty/eleventy-plugin-webc": "^0.11.2",
"@11ty/is-land": "^4.0.1", "@11ty/is-land": "^4.0.1",
"@hotwired/turbo": "^8.0.20", "@hotwired/turbo": "^8.0.20",
"gsap": "^3.14.2",
"lite-youtube-embed": "^0.3.4", "lite-youtube-embed": "^0.3.4",
"tailwindcss": "^3.4.17" "tailwindcss": "^3.4.17"
}, },

View file

@ -1,5 +1,6 @@
import {imageShortcode, imageKeysShortcode} from './shortcodes/image.js'; import {imageShortcode, imageKeysShortcode} from './shortcodes/image.js';
import {svgShortcode} from './shortcodes/svg.js'; import {svgShortcode} from './shortcodes/svg.js';
import {gsapScrollAnim} from './shortcodes/gsap.js';
// Text animation shortcode - wraps each letter in a span with animation class // Text animation shortcode - wraps each letter in a span with animation class
// Speed parameter scales animation duration: // Speed parameter scales animation duration:
@ -18,4 +19,4 @@ const animateText = (content, animation, speed = '1') => {
return letterSpans; return letterSpans;
}; };
export default {imageShortcode, imageKeysShortcode, svgShortcode, animateText}; export default {imageShortcode, imageKeysShortcode, svgShortcode, animateText, gsapScrollAnim};

View file

@ -0,0 +1,95 @@
import {imageKeysShortcode} from './image.js';
/**
* GSAP Scroll Animation Shortcode
* Paired shortcode for creating scroll-controlled GSAP animations with images
*
* Usage:
* {% gsapScrollAnim {
* animationType: "fadeIn",
* scrollStart: "top 80%",
* scrollEnd: "bottom 20%",
* scrub: true,
* containerClass: "my-custom-class"
* } %}
* [{ src: "/path/to/image.jpg", alt: "Alt text", caption: "Caption text" }]
* {% endgsapScrollAnim %}
*/
const parseImagesFromContent = content => {
// Parse JSON array from content
try {
const trimmed = content.trim();
// Check if content is JSON array
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
return JSON.parse(trimmed);
}
// Otherwise return as single item array
return [{ content: trimmed }];
} catch (e) {
console.warn('Failed to parse GSAP shortcode content as JSON:', e);
return [{ content: content }];
}
};
export const gsapScrollAnim = async function(content, configString = '{}') {
// Parse configuration
let config = {};
try {
// Handle both JSON string and object
config = typeof configString === 'string' ? JSON.parse(configString) : configString;
} catch (e) {
console.warn('Failed to parse GSAP config, using defaults:', e);
}
// Set defaults
const {
animationType = 'fadeIn',
scrollStart = 'top 80%',
scrollEnd = 'bottom 20%',
scrub = true,
containerClass = 'gsap-container',
pin = false,
markers = false // Set to true for debugging
} = config;
// Build animation config for data attribute
const animConfig = {
animationType,
scrollStart,
scrollEnd,
scrub,
pin,
markers
};
// Parse images from content
const images = parseImagesFromContent(content);
// Process images using existing image shortcode
const processedImages = await Promise.all(
images.map(async (img, index) => {
if (img.src) {
// Use existing image processing
const imageHtml = await imageKeysShortcode({
src: img.src,
alt: img.alt || '',
caption: img.caption || '',
loading: index === 0 ? 'eager' : 'lazy',
imageClass: 'gsap-image',
containerClass: 'gsap-image-wrapper'
});
return `<div class="gsap-item" data-image-index="${index}">${imageHtml}</div>`;
} else if (img.content) {
// Plain content wrapper
return `<div class="gsap-item">${img.content}</div>`;
}
return '';
})
);
// Build final HTML
return `<div class="${containerClass}" data-gsap-scroll-anim='${JSON.stringify(animConfig)}'>
${processedImages.join('\n ')}
</div>`;
};

View file

@ -51,3 +51,7 @@ schema: BlogPosting
{%- include 'css/post.css' -%} {%- include 'css/post.css' -%}
{%- include 'css/footnotes.css' -%} {%- include 'css/footnotes.css' -%}
{%- endcss -%} {%- endcss -%}
{% js "defer" %}
{% include "scripts/gsap-shortcode-init.js" %}
{% endjs %}

View file

@ -0,0 +1,65 @@
/**
* GSAP Scroll Animation Utilities
* CSS classes for GSAP scroll-driven animations triggered by shortcodes
*/
/* Container for GSAP scroll animations */
.gsap-container {
position: relative;
width: 100%;
}
/* Individual animated items within container */
.gsap-item {
position: relative;
}
/* Image wrappers within GSAP animations */
.gsap-image-wrapper {
position: relative;
overflow: hidden;
}
.gsap-image {
display: block;
width: 100%;
height: auto;
/* GSAP will add will-change when animating */
transform: translateZ(0);
}
/* Initial state - GSAP will handle visibility/opacity */
.gsap-container[data-gsap-scroll-anim] .gsap-item {
transform: translateZ(0);
}
/* Responsive spacing */
.gsap-container .gsap-item + .gsap-item {
margin-top: var(--space-m, 1.5rem);
}
/* When animations are disabled (prefers-reduced-motion) */
@media (prefers-reduced-motion: reduce) {
.gsap-image,
.gsap-item {
will-change: auto !important;
transform: none !important;
opacity: 1 !important;
}
}
/* Loading state */
.gsap-container[data-gsap-loading] {
opacity: 0.5;
}
/* Debug mode - shows animation markers */
.gsap-container[data-gsap-debug] {
outline: 2px dashed red;
outline-offset: 4px;
}
.gsap-container[data-gsap-debug] .gsap-item {
outline: 1px dashed blue;
outline-offset: 2px;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View file

@ -0,0 +1,251 @@
/**
* GSAP Scroll Animation Initializer
* Handles scroll-driven animations triggered by gsapScrollAnim shortcode
* Compatible with Hotwired Turbo navigation
*/
import gsap from 'gsap';
import ScrollTrigger from 'gsap/ScrollTrigger';
// Register GSAP plugins
gsap.registerPlugin(ScrollTrigger);
// Store active contexts for cleanup
const activeContexts = new Map();
/**
* Animation presets
* Each returns GSAP animation properties for the given element(s)
*/
const animations = {
fadeIn: (element) => ({
from: {
opacity: 0,
y: 50
},
to: {
opacity: 1,
y: 0,
ease: 'power2.out'
}
}),
fadeInUp: (element) => ({
from: {
opacity: 0,
y: 100
},
to: {
opacity: 1,
y: 0,
ease: 'power2.out'
}
}),
fadeInDown: (element) => ({
from: {
opacity: 0,
y: -100
},
to: {
opacity: 1,
y: 0,
ease: 'power2.out'
}
}),
scaleIn: (element) => ({
from: {
opacity: 0,
scale: 0.8
},
to: {
opacity: 1,
scale: 1,
ease: 'back.out(1.7)'
}
}),
slideInLeft: (element) => ({
from: {
opacity: 0,
x: -100
},
to: {
opacity: 1,
x: 0,
ease: 'power2.out'
}
}),
slideInRight: (element) => ({
from: {
opacity: 0,
x: 100
},
to: {
opacity: 1,
x: 0,
ease: 'power2.out'
}
}),
parallax: (element) => ({
from: {
y: -100
},
to: {
y: 100,
ease: 'none'
}
}),
stagger: (element) => ({
from: {
opacity: 0,
y: 50
},
to: {
opacity: 1,
y: 0,
ease: 'power2.out',
stagger: 0.2
}
})
};
/**
* Initialize GSAP animations for all containers
*/
function initGsapAnimations() {
// Check if reduced motion is preferred
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (prefersReducedMotion) {
// Skip animations if user prefers reduced motion
console.log('GSAP animations disabled: prefers-reduced-motion');
return;
}
// Find all GSAP animation containers
const containers = document.querySelectorAll('[data-gsap-scroll-anim]');
containers.forEach(container => {
try {
// Parse configuration from data attribute
const config = JSON.parse(container.dataset.gsapScrollAnim);
const {
animationType = 'fadeIn',
scrollStart = 'top 80%',
scrollEnd = 'bottom 20%',
scrub = true,
pin = false,
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(() => {
const anim = animationPreset(items);
// 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'
}
});
// 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);
}
}, container);
// Store context for cleanup
activeContexts.set(container, ctx);
} catch (error) {
console.error('Error initializing GSAP animation:', error, container);
}
});
console.log(`Initialized ${activeContexts.size} GSAP scroll animations`);
}
/**
* Cleanup all active animations
*/
function cleanupGsapAnimations() {
activeContexts.forEach((ctx, container) => {
ctx.revert(); // Reverts all GSAP changes made in this context
container.removeAttribute('data-gsap-active');
});
activeContexts.clear();
// Kill all ScrollTriggers
ScrollTrigger.getAll().forEach(trigger => trigger.kill());
}
/**
* Refresh ScrollTrigger calculations
* Useful when content height changes
*/
function refreshScrollTrigger() {
ScrollTrigger.refresh();
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', initGsapAnimations);
// Turbo Drive compatibility
if (window.Turbo) {
// Clean up before navigation
document.addEventListener('turbo:before-render', cleanupGsapAnimations);
// Re-initialize after navigation
document.addEventListener('turbo:render', initGsapAnimations);
document.addEventListener('turbo:load', () => {
// Slight delay to ensure DOM is ready
setTimeout(initGsapAnimations, 100);
});
// Refresh on Turbo frame load
document.addEventListener('turbo:frame-load', refreshScrollTrigger);
}
// Handle window resize
let resizeTimeout;
window.addEventListener('resize', () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
refreshScrollTrigger();
}, 250);
});
// Export for manual control if needed
export { initGsapAnimations, cleanupGsapAnimations, refreshScrollTrigger, animations };

View file

@ -5,12 +5,45 @@ date: 2026-01-04
tags: ['test'] tags: ['test']
--- ---
Behold: ## GSAP Scroll Animations
Testing the `gsapScrollAnim` shortcode with scroll-driven animations:
{% gsapScrollAnim {
"animationType": "fadeIn",
"scrollStart": "top 80%",
"scrub": true
} %}
[{
"src": "/pages/projects/mixes/tomorrowsbacon/james-whiplash.jpg",
"alt": "Miles Teller as Andrew Neiman from Whiplash",
"caption": "Testing scroll-driven fade in animation"
}]
{% endgsapScrollAnim %}
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam a sem ultrices, congue dui vitae, sodales augue. Mauris congue libero vitae nisi ullamcorper, nec laoreet sapien commodo. Vivamus dignissim urna et metus fermentum porta. Vivamus tempor tortor turpis, in pellentesque nisl viverra eget. Phasellus sed ligula quis massa commodo sagittis. Duis eu rhoncus augue. Vestibulum pretium convallis velit eget pharetra. Nam dapibus lacus eu cursus eleifend. Proin condimentum eros et est volutpat, vitae ullamcorper nulla vulputate. Pellentesque facilisis sem id nulla sodales, eu fermentum erat finibus. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam a sem ultrices, congue dui vitae, sodales augue. Mauris congue libero vitae nisi ullamcorper, nec laoreet sapien commodo. Vivamus dignissim urna et metus fermentum porta. Vivamus tempor tortor turpis, in pellentesque nisl viverra eget. Phasellus sed ligula quis massa commodo sagittis. Duis eu rhoncus augue. Vestibulum pretium convallis velit eget pharetra. Nam dapibus lacus eu cursus eleifend. Proin condimentum eros et est volutpat, vitae ullamcorper nulla vulputate. Pellentesque facilisis sem id nulla sodales, eu fermentum erat finibus.
{% gsapScrollAnim {
"animationType": "slideInRight",
"scrollStart": "top 70%",
"scrub": true,
"containerClass": "gsap-container custom-spacing"
} %}
[{
"src": "/pages/projects/mixes/tomorrowsbacon/gorillaz-cracker-island.jpg",
"alt": "Gorillaz Cracker Island album cover",
"caption": "Image 1 - Stagger animation"
}, {
"src": "/pages/projects/mixes/tomorrowsbacon/radiohead-ok-computer.jpg",
"alt": "Radiohead OK Computer album cover",
"caption": "Image 2 - Stagger animation"
}, {
"src": "/pages/projects/mixes/tomorrowsbacon/the-clash-london-calling.jpeg",
"alt": "The Clash London Calling album cover",
"caption": "Image 3 - Stagger animation"
}]
{% endgsapScrollAnim %}
Proin id risus venenatis arcu sollicitudin venenatis. Ut quam nisl, commodo non urna ac, aliquam ullamcorper justo. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean sed dignissim massa, ac pulvinar sem. Etiam elementum justo lectus, nec blandit tortor varius sollicitudin. Nam tincidunt eros non sodales gravida. Donec pellentesque diam ante, sit amet ullamcorper urna efficitur ut. Quisque ut felis id nisi condimentum rhoncus non non eros. Mauris dapibus id magna et hendrerit. Phasellus in imperdiet quam. Vestibulum euismod leo at augue aliquam venenatis. Nullam quis nisi laoreet nisi laoreet ultricies. Morbi ut facilisis libero. Aliquam odio nunc, sagittis vel elit nec, finibus tristique velit. Nunc suscipit venenatis magna ut aliquet. Praesent sodales orci gravida facilisis finibus. Proin id risus venenatis arcu sollicitudin venenatis. Ut quam nisl, commodo non urna ac, aliquam ullamcorper justo. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean sed dignissim massa, ac pulvinar sem. Etiam elementum justo lectus, nec blandit tortor varius sollicitudin. Nam tincidunt eros non sodales gravida. Donec pellentesque diam ante, sit amet ullamcorper urna efficitur ut. Quisque ut felis id nisi condimentum rhoncus non non eros. Mauris dapibus id magna et hendrerit. Phasellus in imperdiet quam. Vestibulum euismod leo at augue aliquam venenatis. Nullam quis nisi laoreet nisi laoreet ultricies. Morbi ut facilisis libero. Aliquam odio nunc, sagittis vel elit nec, finibus tristique velit. Nunc suscipit venenatis magna ut aliquet. Praesent sodales orci gravida facilisis finibus.
Donec non pretium lectus. Aliquam pulvinar mattis egestas. Phasellus sit amet magna maximus velit dictum convallis. Nam mollis porttitor libero eu ornare. Cras sit amet leo ac mauris lacinia sodales. Maecenas pretium eu sapien sit amet interdum. Mauris sodales accumsan nibh vitae facilisis. Duis leo massa, placerat ut luctus quis, condimentum id dolor. Ut ut velit a ipsum mattis maximus. Praesent porta justo a lectus pretium iaculis. Donec non pretium lectus. Aliquam pulvinar mattis egestas. Phasellus sit amet magna maximus velit dictum convallis. Nam mollis porttitor libero eu ornare. Cras sit amet leo ac mauris lacinia sodales. Maecenas pretium eu sapien sit amet interdum. Mauris sodales accumsan nibh vitae facilisis. Duis leo massa, placerat ut luctus quis, condimentum id dolor. Ut ut velit a ipsum mattis maximus. Praesent porta justo a lectus pretium iaculis.