diff --git a/docs/GSAP_ANIMATIONS.md b/docs/GSAP_ANIMATIONS.md new file mode 100644 index 0000000..51088b6 --- /dev/null +++ b/docs/GSAP_ANIMATIONS.md @@ -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 +
+
+
+ + ... + +
...
+
+
+
+``` + +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 diff --git a/eleventy.config.js b/eleventy.config.js index 037684a..bbddffb 100644 --- a/eleventy.config.js +++ b/eleventy.config.js @@ -97,6 +97,7 @@ export default async function (eleventyConfig) { eleventyConfig.addShortcode('image', shortcodes.imageShortcode); eleventyConfig.addShortcode('imageKeys', shortcodes.imageKeysShortcode); eleventyConfig.addShortcode('animateText', shortcodes.animateText); + eleventyConfig.addPairedShortcode('gsapScrollAnim', shortcodes.gsapScrollAnim); eleventyConfig.addShortcode('year', () => `${new Date().getFullYear()}`); diff --git a/package-lock.json b/package-lock.json index d5a6fb6..4592f41 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@11ty/eleventy-plugin-webc": "^0.11.2", "@11ty/is-land": "^4.0.1", "@hotwired/turbo": "^8.0.20", + "gsap": "^3.14.2", "lite-youtube-embed": "^0.3.4", "tailwindcss": "^3.4.17" }, @@ -4230,6 +4231,12 @@ "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": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", diff --git a/package.json b/package.json index 4b01239..e467e16 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@11ty/eleventy-plugin-webc": "^0.11.2", "@11ty/is-land": "^4.0.1", "@hotwired/turbo": "^8.0.20", + "gsap": "^3.14.2", "lite-youtube-embed": "^0.3.4", "tailwindcss": "^3.4.17" }, diff --git a/src/_config/shortcodes.js b/src/_config/shortcodes.js index 5d768de..50c948a 100644 --- a/src/_config/shortcodes.js +++ b/src/_config/shortcodes.js @@ -1,5 +1,6 @@ import {imageShortcode, imageKeysShortcode} from './shortcodes/image.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 // Speed parameter scales animation duration: @@ -18,4 +19,4 @@ const animateText = (content, animation, speed = '1') => { return letterSpans; }; -export default {imageShortcode, imageKeysShortcode, svgShortcode, animateText}; +export default {imageShortcode, imageKeysShortcode, svgShortcode, animateText, gsapScrollAnim}; diff --git a/src/_config/shortcodes/gsap.js b/src/_config/shortcodes/gsap.js new file mode 100644 index 0000000..870434d --- /dev/null +++ b/src/_config/shortcodes/gsap.js @@ -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 `
${imageHtml}
`; + } else if (img.content) { + // Plain content wrapper + return `
${img.content}
`; + } + return ''; + }) + ); + + // Build final HTML + return `
+ ${processedImages.join('\n ')} +
`; +}; diff --git a/src/_layouts/post.njk b/src/_layouts/post.njk index efe49c9..e2a8542 100644 --- a/src/_layouts/post.njk +++ b/src/_layouts/post.njk @@ -51,3 +51,7 @@ schema: BlogPosting {%- include 'css/post.css' -%} {%- include 'css/footnotes.css' -%} {%- endcss -%} + +{% js "defer" %} + {% include "scripts/gsap-shortcode-init.js" %} +{% endjs %} diff --git a/src/assets/css/global/utilities/gsap-animations.css b/src/assets/css/global/utilities/gsap-animations.css new file mode 100644 index 0000000..19df13b --- /dev/null +++ b/src/assets/css/global/utilities/gsap-animations.css @@ -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; +} diff --git a/src/assets/og-images/testing-preview.jpeg b/src/assets/og-images/testing-preview.jpeg new file mode 100644 index 0000000..2b0ee78 Binary files /dev/null and b/src/assets/og-images/testing-preview.jpeg differ diff --git a/src/assets/scripts/bundle/gsap-shortcode-init.js b/src/assets/scripts/bundle/gsap-shortcode-init.js new file mode 100644 index 0000000..da3181a --- /dev/null +++ b/src/assets/scripts/bundle/gsap-shortcode-init.js @@ -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 }; diff --git a/src/posts/2025/testing/testing.md b/src/posts/2025/testing/testing.md index feb4990..a05f60e 100644 --- a/src/posts/2025/testing/testing.md +++ b/src/posts/2025/testing/testing.md @@ -5,12 +5,45 @@ date: 2026-01-04 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. +{% 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. 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.