From wordpress-dev-skills
GSAP animation best practices for web design - scroll triggers, performance optimization, accessibility, responsive animations, and testing integration. Use when implementing or reviewing animations on WordPress or any web project.
How this skill is triggered — by the user, by Claude, or both
Slash command
/wordpress-dev-skills:gsap-animationsThis skill is limited to the following tools:
The summary Claude sees in its skill listing — used to decide when to auto-load this skill
Comprehensive guide for implementing professional, accessible, and performant animations using GSAP (GreenSock Animation Platform).
Comprehensive guide for implementing professional, accessible, and performant animations using GSAP (GreenSock Animation Platform).
transform and opacity only (GPU-accelerated)width, height, top, left, margin, paddingwill-change sparinglyprefers-reduced-motion<!-- CDN (recommended for WordPress) -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/ScrollTrigger.min.js"></script>
<!-- Optional plugins -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/ScrollSmoother.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/SplitText.min.js"></script>
function theme_enqueue_gsap() {
// GSAP Core
wp_enqueue_script(
'gsap',
'https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js',
array(),
'3.12.5',
true
);
// ScrollTrigger
wp_enqueue_script(
'gsap-scrolltrigger',
'https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/ScrollTrigger.min.js',
array('gsap'),
'3.12.5',
true
);
// Theme animations
wp_enqueue_script(
'theme-animations',
get_theme_file_uri('/assets/js/animations.js'),
array('gsap', 'gsap-scrolltrigger'),
filemtime(get_theme_file_path('/assets/js/animations.js')),
true
);
}
add_action('wp_enqueue_scripts', 'theme_enqueue_gsap');
// Basic fade in
gsap.from('.fade-in', {
opacity: 0,
y: 50,
duration: 1,
stagger: 0.2,
scrollTrigger: {
trigger: '.fade-in',
start: 'top 80%',
toggleActions: 'play none none none'
}
});
// Cards appearing one by one
gsap.from('.card', {
opacity: 0,
y: 100,
duration: 0.8,
stagger: {
amount: 0.6,
from: 'start'
},
ease: 'power2.out',
scrollTrigger: {
trigger: '.cards-container',
start: 'top 75%'
}
});
// Subtle parallax on images
gsap.to('.parallax-image', {
yPercent: -20,
ease: 'none',
scrollTrigger: {
trigger: '.parallax-section',
start: 'top bottom',
end: 'bottom top',
scrub: true
}
});
// Requires SplitText plugin (Club GreenSock)
// Or use CSS-based alternative below
// CSS Alternative - wrap each line in a span
gsap.from('.reveal-line', {
opacity: 0,
y: '100%',
duration: 0.8,
stagger: 0.1,
ease: 'power3.out',
scrollTrigger: {
trigger: '.text-reveal',
start: 'top 80%'
}
});
// Image revealed by sliding mask
gsap.to('.curtain-mask', {
scaleX: 0,
transformOrigin: 'right center',
duration: 1.2,
ease: 'power4.inOut',
scrollTrigger: {
trigger: '.curtain-container',
start: 'top 70%'
}
});
// Complex hero sequence
const heroTL = gsap.timeline({
defaults: { ease: 'power3.out' }
});
heroTL
.from('.hero-bg', { scale: 1.2, duration: 1.5 })
.from('.hero-title', { opacity: 0, y: 100, duration: 1 }, '-=1')
.from('.hero-subtitle', { opacity: 0, y: 50, duration: 0.8 }, '-=0.5')
.from('.hero-cta', { opacity: 0, y: 30, duration: 0.6 }, '-=0.3');
// Check user preference
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// Option 1: Disable all animations
if (prefersReducedMotion) {
gsap.globalTimeline.timeScale(0);
ScrollTrigger.getAll().forEach(st => st.kill());
}
// Option 2: Simplified animations
const animationConfig = prefersReducedMotion
? { duration: 0, stagger: 0 }
: { duration: 1, stagger: 0.2 };
gsap.from('.element', {
opacity: 0,
y: prefersReducedMotion ? 0 : 50,
...animationConfig
});
/* Ensure content visible without JS */
.fade-in {
opacity: 1;
transform: translateY(0);
}
/* Only hide if animations will run */
.js .fade-in {
opacity: 0;
transform: translateY(50px);
}
/* Respect reduced motion in CSS too */
@media (prefers-reduced-motion: reduce) {
.js .fade-in {
opacity: 1;
transform: none;
}
}
// Add at start of script
document.documentElement.classList.add('js');
// Create responsive animations
const mm = gsap.matchMedia();
mm.add('(min-width: 1024px)', () => {
// Desktop animations
gsap.from('.hero-image', {
x: 100,
opacity: 0,
duration: 1.2
});
return () => {
// Cleanup on breakpoint change
};
});
mm.add('(max-width: 1023px)', () => {
// Mobile animations (simpler)
gsap.from('.hero-image', {
opacity: 0,
duration: 0.8
});
});
// Recalculate ScrollTrigger on resize
let resizeTimer;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
ScrollTrigger.refresh();
}, 250);
});
// GOOD - GPU accelerated
gsap.to('.element', {
x: 100, // transform: translateX
y: 50, // transform: translateY
rotation: 45, // transform: rotate
scale: 1.2, // transform: scale
opacity: 0.5
});
// BAD - Causes layout/paint
gsap.to('.element', {
left: 100, // Triggers layout
width: '200px', // Triggers layout
marginTop: 50 // Triggers layout
});
// Use batch for many similar elements
ScrollTrigger.batch('.card', {
onEnter: batch => gsap.to(batch, {
opacity: 1,
y: 0,
stagger: 0.1
}),
start: 'top 85%'
});
// Cleanup when navigating (SPA) or component unmount
function cleanup() {
ScrollTrigger.getAll().forEach(st => st.kill());
gsap.killTweensOf('*');
}
// Only initialize animations for visible sections
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
initSectionAnimations(entry.target);
observer.unobserve(entry.target);
}
});
}, { rootMargin: '100px' });
document.querySelectorAll('.animated-section').forEach(section => {
observer.observe(section);
});
// Avoid common mistakes
ScrollTrigger.create({
trigger: '.section',
start: 'top 80%', // When top of trigger hits 80% from top of viewport
end: 'bottom 20%', // When bottom of trigger hits 20% from top
markers: true, // Debug only - remove in production!
});
// Pinning can cause layout issues
ScrollTrigger.create({
trigger: '.pinned-section',
start: 'top top',
end: '+=100%',
pin: true,
pinSpacing: true, // Usually want this true
anticipatePin: 1 // Helps with mobile
});
// Wait for images before calculating positions
ScrollTrigger.config({
ignoreMobileResize: true
});
window.addEventListener('load', () => {
ScrollTrigger.refresh();
});
// Or refresh after lazy images load
document.querySelectorAll('img[loading="lazy"]').forEach(img => {
img.addEventListener('load', () => ScrollTrigger.refresh());
});
For the visual-qa skill to capture animations correctly:
// Expose function to complete all animations instantly
window.completeAllAnimations = function() {
gsap.globalTimeline.progress(1);
ScrollTrigger.getAll().forEach(st => {
st.scroll(st.end);
});
};
// Or skip animations entirely for screenshots
if (window.location.search.includes('skip-animations')) {
gsap.globalTimeline.timeScale(100);
}
// In Playwright test
await page.evaluate(() => {
if (window.completeAllAnimations) {
window.completeAllAnimations();
}
});
await page.waitForTimeout(500);
await page.screenshot({ path: 'screenshot.png', fullPage: true });
// animations.js - Reusable animation library
const Animations = {
// Initialize all animations
init() {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
return;
}
this.fadeIn();
this.slideIn();
this.parallax();
this.textReveal();
},
fadeIn() {
gsap.utils.toArray('[data-animate="fade-in"]').forEach(el => {
gsap.from(el, {
opacity: 0,
y: 50,
duration: 0.8,
scrollTrigger: {
trigger: el,
start: 'top 85%',
once: true
}
});
});
},
slideIn() {
gsap.utils.toArray('[data-animate="slide-left"]').forEach(el => {
gsap.from(el, {
opacity: 0,
x: -100,
duration: 1,
scrollTrigger: {
trigger: el,
start: 'top 80%',
once: true
}
});
});
gsap.utils.toArray('[data-animate="slide-right"]').forEach(el => {
gsap.from(el, {
opacity: 0,
x: 100,
duration: 1,
scrollTrigger: {
trigger: el,
start: 'top 80%',
once: true
}
});
});
},
parallax() {
gsap.utils.toArray('[data-parallax]').forEach(el => {
const speed = el.dataset.parallax || 0.2;
gsap.to(el, {
yPercent: -100 * speed,
ease: 'none',
scrollTrigger: {
trigger: el.parentElement,
start: 'top bottom',
end: 'bottom top',
scrub: true
}
});
});
},
textReveal() {
gsap.utils.toArray('[data-animate="text-reveal"]').forEach(el => {
const lines = el.querySelectorAll('.line');
gsap.from(lines, {
opacity: 0,
y: '100%',
duration: 0.8,
stagger: 0.1,
scrollTrigger: {
trigger: el,
start: 'top 80%',
once: true
}
});
});
},
// Refresh after dynamic content
refresh() {
ScrollTrigger.refresh();
},
// Cleanup for SPA navigation
destroy() {
ScrollTrigger.getAll().forEach(st => st.kill());
gsap.killTweensOf('*');
}
};
// Initialize on DOM ready
document.addEventListener('DOMContentLoaded', () => Animations.init());
<!-- Fade in -->
<div data-animate="fade-in">Content</div>
<!-- Slide from left -->
<div data-animate="slide-left">Content</div>
<!-- Parallax (0.2 = 20% speed) -->
<img data-parallax="0.3" src="image.jpg">
<!-- Text reveal (requires line wrapping) -->
<div data-animate="text-reveal">
<div class="line">First line</div>
<div class="line">Second line</div>
</div>
ScrollTrigger.defaults({
markers: true // Shows start/end markers
});
gsap.to('.element', {
x: 100,
onStart: () => console.log('Animation started'),
onComplete: () => console.log('Animation completed'),
onUpdate: self => console.log('Progress:', self.progress())
});
// List all ScrollTriggers
console.log('ScrollTriggers:', ScrollTrigger.getAll());
// Check if element exists
const el = document.querySelector('.animated-element');
if (!el) console.warn('Animation target not found!');
markers: trueprefers-reduced-motion: reducenpx claudepluginhub salemaziel/wordpress-dev-skillsCreates structured, bite-sized implementation plans from specs or requirements before writing code. Useful for breaking down multi-step tasks into testable steps with file structure and task boundaries.