Adding JavaScript Behavior
Introduction
Section titled “Introduction”Let’s add interactive behavior to our card using GSAP animations. We’ll create a custom animation that scales the media wrapper on scroll and adds hover effects.
Creating the Animation Class
Section titled “Creating the Animation Class”Create: src/scripts/module/CustomAnimation.js
/** * CustomAnimation * * Demonstrates a basic GSAP animation that: * - Scales and fades elements on scroll * - Uses ScrollTrigger for viewport detection * - Follows Terra's class structure pattern */
class CustomAnimation { constructor({ element, Manager }) { // Store DOM reference this.DOM = { element: element, mediaWrapper: element.querySelector('.c--card-test__media-wrapper'), title: element.querySelector('.c--card-test__wrapper__title'), subtitle: element.querySelector('.c--card-test__wrapper__subtitle'), };
// Get GSAP from Manager this.gsap = Manager.getLibrary("GSAP").gsap; this.ScrollTrigger = Manager.getLibrary("ScrollTrigger");
// Initialize this.init(); this.events(); }
init() { // Set initial states this.gsap.set(this.DOM.mediaWrapper, { scale: 0.8, opacity: 0, });
this.gsap.set([this.DOM.title, this.DOM.subtitle], { opacity: 0, y: 20, });
// Create animation timeline this.timeline = this.gsap.timeline({ paused: true, defaults: { duration: 0.6, ease: "power3.out", }, });
// Add animations to timeline this.timeline .to(this.DOM.mediaWrapper, { scale: 1, opacity: 1, duration: 0.8, }) .to(this.DOM.title, { opacity: 1, y: 0, }, "-=0.4") // Start 0.4s before previous animation ends .to(this.DOM.subtitle, { opacity: 1, y: 0, }, "-=0.5");
// Setup ScrollTrigger this.scrollTrigger = this.ScrollTrigger.create({ trigger: this.DOM.element, start: "top 80%", once: true, onEnter: () => { this.timeline.play(); }, }); }
events() { // Add hover effect for media wrapper if (this.DOM.mediaWrapper) { this.DOM.mediaWrapper.addEventListener('mouseenter', () => { this.gsap.to(this.DOM.mediaWrapper, { scale: 1.05, duration: 0.3, ease: "power2.out", }); });
this.DOM.mediaWrapper.addEventListener('mouseleave', () => { this.gsap.to(this.DOM.mediaWrapper, { scale: 1, duration: 0.3, ease: "power2.out", }); }); } }
destroy() { // Clean up ScrollTrigger if (this.scrollTrigger) { this.scrollTrigger.kill(); this.scrollTrigger = null; }
// Kill timeline if (this.timeline) { this.timeline.kill(); this.timeline = null; }
// Remove event listeners if (this.DOM.mediaWrapper) { // Clone and replace to remove all listeners const newElement = this.DOM.mediaWrapper.cloneNode(true); this.DOM.mediaWrapper.parentNode.replaceChild(newElement, this.DOM.mediaWrapper); }
// Clear DOM references this.DOM = null; }}
export default CustomAnimation;Understanding the Animation Class
Section titled “Understanding the Animation Class”Constructor Pattern
Section titled “Constructor Pattern”constructor({ element, Manager }) { this.DOM = { element: element }; this.gsap = Manager.getLibrary("GSAP").gsap; this.init(); this.events();}Terra’s Four Pillars:
- Constructor - Setup and initialization
- init() - Create animations and configure state
- events() - Attach event listeners
- destroy() - Clean up when component unmounts
📚 Learn more about Classes Structure in Chapter 5-2
GSAP Timeline
Section titled “GSAP Timeline”this.timeline = this.gsap.timeline({ paused: true, // Don't auto-play defaults: { duration: 0.6, ease: "power3.out" }});Why paused?
- We control when it plays via ScrollTrigger
- Prevents animation from running before element is visible
- Allows coordination with page lifecycle
Default Properties:
duration: 0.6- All animations default to 0.6 secondsease: "power3.out"- Smooth deceleration at end
Timeline Overlap
Section titled “Timeline Overlap”.to(this.DOM.title, { opacity: 1, y: 0 }).to(this.DOM.subtitle, { opacity: 1, y: 0 }, "-=0.5")// ↑// Start 0.5s before previous endsPosition Parameters:
"-=0.5"- Overlap by 0.5 seconds (smooth stagger)"+=0.2"- Add 0.2 second gap"<"- Start with previous animation">"- Start after previous animation
📚 Learn more about GSAP Animations in Chapter 5-4
ScrollTrigger Setup
Section titled “ScrollTrigger Setup”this.scrollTrigger = this.ScrollTrigger.create({ trigger: this.DOM.element, // Element to watch start: "top 80%", // When top hits 80% viewport once: true, // Only trigger once onEnter: () => { this.timeline.play(); // Play animation },});Position Values:
"top 80%"- Element’s top edge is at 80% down the viewport"top center"- Element’s top edge is at viewport center"bottom 20%"- Element’s bottom edge is at 20% down viewport
ScrollTrigger Options:
trigger- Element to observestart- When to start (element position, viewport position)once- Only fire once (for entrance animations)onEnter- Callback when trigger starts
Destroy Method
Section titled “Destroy Method”destroy() { if (this.scrollTrigger) { this.scrollTrigger.kill(); // Remove ScrollTrigger this.scrollTrigger = null; } if (this.timeline) { this.timeline.kill(); // Kill timeline this.timeline = null; } this.DOM = null; // Clear references}Why destroy is critical:
- ✅ Prevents memory leaks during SWUP page transitions
- ✅ Removes event listeners
- ✅ Cleans up GSAP instances
- ✅ Required for all interactive components
Creating the Handler
Section titled “Creating the Handler”Create: src/scripts/handler/customAnimation/CustomAnimationHandler.js
import CoreHandler from "../CoreHandler";
/** * CustomAnimationHandler * * Manages lifecycle of CustomAnimation instances across page transitions. * Detects .js--custom-animation elements and creates CustomAnimation instances. */
class CustomAnimationHandler extends CoreHandler { constructor(payload) { super(payload); this.init(); this.events(); this.config = {}; }
get updateTheDOM() { return { customAnimationElements: document.querySelectorAll('.js--custom-animation'), }; }
init() { super.init(); super.getLibraryName("CustomAnimation"); }
events() { super.events();
// When new content loads this.emitter.on("MitterContentReplaced", async () => { this.DOM = this.updateTheDOM; this.Manager.instances.CustomAnimation = [];
super.assignInstances({ elementGroups: [ { elements: this.DOM.customAnimationElements, config: { ...this.config, }, // Load immediately (no boostify) // For below-fold, add: boostify: { distance: 50 } }, ], }); });
// Before content is removed this.emitter.on("MitterWillReplaceContent", () => { if (this.DOM.customAnimationElements.length) { super.destroyInstances({ libraryName: 'CustomAnimation' }); } }); }}
export default CustomAnimationHandler;Understanding the Handler
Section titled “Understanding the Handler”Lifecycle Events
Section titled “Lifecycle Events”this.emitter.on("MitterContentReplaced", async () => { // New page content loaded - create instances});
this.emitter.on("MitterWillReplaceContent", () => { // Before page change - destroy instances});SWUP Lifecycle:
- User clicks link
MitterWillReplaceContentfires → destroy instances- Out animation plays
- SWUP fetches new page
- Content replaced
- In animation plays
MitterContentReplacedfires → create instances
📚 Learn more about Handlers in Chapter 5-3
Instance Management
Section titled “Instance Management”super.assignInstances({ elementGroups: [ { elements: this.DOM.customAnimationElements, config: { ...this.config }, }, ],});assignInstances automatically:
- Loops through elements
- Creates CustomAnimation instance for each
- Stores in
Manager.instances.CustomAnimation[] - Tracks for cleanup
Boostify Integration
Section titled “Boostify Integration”elementGroups: [ { elements: this.DOM.customAnimationElements, config: { ...this.config }, boostify: { distance: 50 }, // Load when 50px from viewport },]When to use Boostify:
- ✅ Below-fold components (performance optimization)
- ✅ Heavy components (large libraries, complex setup)
- ❌ Above-fold critical components
- ❌ Small, lightweight components
📚 Learn more about Libraries in Chapter 5-5
Registering Animation Asset
Section titled “Registering Animation Asset”Add to src/scripts/preload/extraAssets.js:
export const getExtraAssets = () => { return [ // ... other assets { name: "CustomAnimation", domElement: document.querySelector(".js--custom-animation"), resource: async () => { const { default: CustomAnimation } = await import( "@scripts/module/CustomAnimation" ); return CustomAnimation; }, }, ];};This tells the framework:
- Asset name:
"CustomAnimation" - When to load: When
.js--custom-animationelement exists - What to load: The CustomAnimation class
Registering Handler
Section titled “Registering Handler”Add to src/scripts/Main.js:
import CustomAnimationHandler from "@handler/customAnimation/CustomAnimationHandler";
class Main { constructor(payload) { // ... other handlers new CustomAnimationHandler(payload); }}This instantiates the handler when the app starts.
Allocating Instances
Section titled “Allocating Instances”Add to src/scripts/Project.js:
this.Manager.instances = { // ... other instances CustomAnimation: [],};This creates an array to store CustomAnimation instances.
Using the Animation
Section titled “Using the Animation”Add the class to your component in the template:
<Btn language={language} payload={{ button: { ...link }, customContent: true, customClass: ["c--card-test js--custom-animation", customClass] .filter(Boolean) .join(" "), }}> <!-- component content --></Btn>The js--custom-animation class:
- Triggers the CustomAnimationHandler
- Creates a CustomAnimation instance
- Automatically managed through page transitions
Complete Integration Checklist
Section titled “Complete Integration Checklist”- Create
CustomAnimation.jsclass - Create
CustomAnimationHandler.jshandler - Add asset to
extraAssets.js - Register handler in
Main.js - Allocate instances in
Project.js - Add
js--custom-animationclass to component
Testing Your Animation
Section titled “Testing Your Animation”Manual Testing
Section titled “Manual Testing”-
Initial Load
- Scroll to component
- Animation should trigger at 80% viewport
- Elements should fade and scale in
-
Hover Behavior
- Hover over media wrapper
- Should scale to 1.05
- Should scale back on mouse leave
-
Page Transitions
- Navigate to another page
- Navigate back
- Animation should re-initialize and work again
Debug Mode
Section titled “Debug Mode”Add this to your animation class for debugging:
init() { console.log('CustomAnimation initialized', this.DOM.element); // ... rest of init}
destroy() { console.log('CustomAnimation destroyed'); // ... rest of destroy}Common Issues & Solutions
Section titled “Common Issues & Solutions”Issue 1: Animation doesn’t trigger
Section titled “Issue 1: Animation doesn’t trigger”Solutions:
- Check
js--custom-animationclass is applied - Verify handler is registered in
Main.js - Confirm instances allocated in
Project.js - Check browser console for errors
Issue 2: Animation triggers multiple times
Section titled “Issue 2: Animation triggers multiple times”Solutions:
- Ensure
once: truein ScrollTrigger - Check destroy method kills ScrollTrigger
- Verify no duplicate handlers in
Main.js
Issue 3: Animation persists after page change
Section titled “Issue 3: Animation persists after page change”Solutions:
- Implement
destroy()method - Kill all ScrollTriggers
- Kill timelines
- Clear DOM references
Issue 4: Hover effects conflict with scroll animation
Section titled “Issue 4: Hover effects conflict with scroll animation”Solutions:
- Only apply hover after scroll animation completes
- Use separate timelines for hover and scroll
- Check z-index and pointer-events
Next Steps
Section titled “Next Steps”Excellent! Your component now has smooth animations. Let’s compose it into a flexible module.
👉 Continue to Building Flexible Modules to learn how to create reusable section-level layouts.