Skip to content

Adding JavaScript Behavior

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.


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;

constructor({ element, Manager }) {
this.DOM = { element: element };
this.gsap = Manager.getLibrary("GSAP").gsap;
this.init();
this.events();
}

Terra’s Four Pillars:

  1. Constructor - Setup and initialization
  2. init() - Create animations and configure state
  3. events() - Attach event listeners
  4. destroy() - Clean up when component unmounts

📚 Learn more about Classes Structure in Chapter 5-2


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 seconds
  • ease: "power3.out" - Smooth deceleration at end

.to(this.DOM.title, { opacity: 1, y: 0 })
.to(this.DOM.subtitle, { opacity: 1, y: 0 }, "-=0.5")
// ↑
// Start 0.5s before previous ends

Position 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


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 observe
  • start - When to start (element position, viewport position)
  • once - Only fire once (for entrance animations)
  • onEnter - Callback when trigger starts

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

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;

this.emitter.on("MitterContentReplaced", async () => {
// New page content loaded - create instances
});
this.emitter.on("MitterWillReplaceContent", () => {
// Before page change - destroy instances
});

SWUP Lifecycle:

  1. User clicks link
  2. MitterWillReplaceContent fires → destroy instances
  3. Out animation plays
  4. SWUP fetches new page
  5. Content replaced
  6. In animation plays
  7. MitterContentReplaced fires → create instances

📚 Learn more about Handlers in Chapter 5-3


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

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


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-animation element exists
  • What to load: The CustomAnimation class

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.


Add to src/scripts/Project.js:

this.Manager.instances = {
// ... other instances
CustomAnimation: [],
};

This creates an array to store CustomAnimation instances.


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

  • Create CustomAnimation.js class
  • Create CustomAnimationHandler.js handler
  • Add asset to extraAssets.js
  • Register handler in Main.js
  • Allocate instances in Project.js
  • Add js--custom-animation class to component

  1. Initial Load

    • Scroll to component
    • Animation should trigger at 80% viewport
    • Elements should fade and scale in
  2. Hover Behavior

    • Hover over media wrapper
    • Should scale to 1.05
    • Should scale back on mouse leave
  3. Page Transitions

    • Navigate to another page
    • Navigate back
    • Animation should re-initialize and work again

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
}

Solutions:

  1. Check js--custom-animation class is applied
  2. Verify handler is registered in Main.js
  3. Confirm instances allocated in Project.js
  4. Check browser console for errors

Issue 2: Animation triggers multiple times

Section titled “Issue 2: Animation triggers multiple times”

Solutions:

  1. Ensure once: true in ScrollTrigger
  2. Check destroy method kills ScrollTrigger
  3. Verify no duplicate handlers in Main.js

Issue 3: Animation persists after page change

Section titled “Issue 3: Animation persists after page change”

Solutions:

  1. Implement destroy() method
  2. Kill all ScrollTriggers
  3. Kill timelines
  4. Clear DOM references

Issue 4: Hover effects conflict with scroll animation

Section titled “Issue 4: Hover effects conflict with scroll animation”

Solutions:

  1. Only apply hover after scroll animation completes
  2. Use separate timelines for hover and scroll
  3. Check z-index and pointer-events

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.