/**
* GSAP SplitText Animation - Final Fix
* Using GSAP SplitText plugin (available free in Webflow)
*
* Features:
* - ScrollTrigger integration
* - autoSplit for proper font loading
* - Preserves text-align: justify
* - CSS handles muted opacity (not the script!)
* - Clean animations without style interference
*/
(function() {
'use strict';
function initGSAPTextReveal() {
// Check dependencies
if (typeof gsap === 'undefined') {
console.error('â GSAP not loaded!');
return;
}
if (typeof ScrollTrigger === 'undefined') {
console.error('â ScrollTrigger not loaded!');
return;
}
if (typeof SplitText === 'undefined') {
console.error('â SplitText not loaded! In Webflow this is included automatically.');
return;
}
// Register plugins
gsap.registerPlugin(ScrollTrigger, SplitText);
console.log('đŦ GSAP SplitText Animation initialized');
// Initialize scroll-triggered line animations
initScrollAnimation({
selector: '.split-text-animation',
type: 'lines',
animateTarget: 'lines',
duration: 2.0,
stagger: 0.2,
yOffset: 30,
ease: 'power4.out',
triggerStart: 'top 85%'
});
// Initialize scroll-triggered character animations
initScrollAnimation({
selector: '.split-text-chars',
type: 'words,chars',
animateTarget: 'chars',
duration: 2.5,
stagger: 0.005,
yOffset: 80,
ease: 'power4.out',
triggerStart: 'top 85%'
});
// Initialize on-load line animations
initOnLoadAnimation({
selector: '.split-text-animation-onload',
type: 'lines',
animateTarget: 'lines',
duration: 2.0,
stagger: 0.2,
yOffset: 30,
ease: 'power4.out',
delay: 0.3
});
// Initialize on-load character animations
initOnLoadAnimation({
selector: '.split-text-chars-onload',
type: 'words,chars',
animateTarget: 'chars',
duration: 2.5,
stagger: 0.005,
yOffset: 80,
ease: 'power4.out',
delay: 0.3
});
// Initialize trigger-based animations
initTriggerAnimation();
}
/**
* Check if element has justify alignment
*/
function hasJustifyAlignment(element) {
if (element.classList.contains('align-justify')) {
return true;
}
const computedStyle = window.getComputedStyle(element);
return computedStyle.textAlign === 'justify';
}
/**
* Initialize scroll-triggered animation
*/
function initScrollAnimation(config) {
const elements = document.querySelectorAll(config.selector);
if (!elements.length) {
console.log(`âšī¸ No elements found: "${config.selector}"`);
return;
}
elements.forEach((element, index) => {
let scrollTrigger = null;
const isJustified = hasJustifyAlignment(element);
// Override for justified text - ONLY split lines
let actualSplitType = config.type;
let actualAnimateTarget = config.animateTarget || 'lines';
if (isJustified && config.animateTarget === 'chars') {
console.warn(`â ī¸ Element with justify alignment detected. Switching to line animation.`);
actualSplitType = 'lines';
actualAnimateTarget = 'lines';
}
// Preserve star icon spans - prevent whitespace collapse
const starSpans = element.querySelectorAll('.header114_text-spacer');
starSpans.forEach(span => {
if (span.textContent.trim() === '') {
span.innerHTML = ''; // Zero-width space prevents SplitText from collapsing the span
}
});
// Create SplitText
SplitText.create(element, {
type: actualSplitType,
deepSlice: true,
aria: "auto",
autoSplit: true,
smartWrap: actualAnimateTarget === 'chars',
reduceWhiteSpace: !isJustified,
onSplit: function(self) {
// Determine what to animate
let targets = null;
if (actualAnimateTarget === 'chars' && self.chars && self.chars.length) {
targets = self.chars;
} else if (actualAnimateTarget === 'lines' && self.lines && self.lines.length) {
targets = self.lines;
} else if (self.words && self.words.length) {
targets = self.words;
} else if (self.lines && self.lines.length) {
targets = self.lines;
actualAnimateTarget = 'lines';
}
if (!targets || !targets.length) {
console.warn(`â ī¸ No valid targets found for animation in element #${index}`);
return;
}
// Apply word wrapping CSS (only for char animations)
if (actualAnimateTarget === 'chars' && self.words && self.words.length) {
self.words.forEach(word => {
word.style.whiteSpace = 'nowrap';
word.style.display = 'inline-block';
});
}
// For justified text with lines
if (isJustified && self.lines && self.lines.length) {
self.lines.forEach((line, lineIndex) => {
line.style.display = 'inline-block';
line.style.textAlign = 'justify';
// Justify all lines except the last one
if (lineIndex < self.lines.length - 1) {
line.style.textAlignLast = 'justify';
} else {
// Last line: left-aligned, not stretched
line.style.textAlignLast = 'start';
}
line.style.width = '100%';
});
element.style.textAlign = 'justify';
}
// Preserve star icon spans (must stay visible with correct styling)
const starSpans = element.querySelectorAll('.header114_text-spacer');
// Set initial state
gsap.set(targets, {
y: config.yOffset,
opacity: 0
});
// Keep star icons visible with their original CSS properties
if (starSpans.length) {
starSpans.forEach(span => {
gsap.set(span, {
opacity: 1,
y: 0,
width: '6.25rem',
maxWidth: '6.25rem',
display: 'inline-flex',
clearProps: 'transform,translate,rotate,scale'
});
});
}
// Handle empty lines (from
tags) - replace with actual BR tags
if (self.lines && self.lines.length > 1) {
const linesToRemove = [];
self.lines.forEach((line, i) => {
const lineText = line.textContent.trim();
// Empty line = was a
tag
if (lineText === '' || lineText === '\n') {
// Insert a real
tag BEFORE this empty line
const br = document.createElement('br');
line.parentNode.insertBefore(br, line);
// Mark this empty line for removal
linesToRemove.push(line);
}
});
// Remove empty line divs (they're no longer needed)
linesToRemove.forEach(line => {
line.remove();
});
}
// Kill existing ScrollTrigger
if (scrollTrigger) {
scrollTrigger.kill();
}
// Create ScrollTrigger
scrollTrigger = ScrollTrigger.create({
trigger: element,
start: config.triggerStart,
onEnter: () => {
// Animate to opacity 1 - CSS handles muted styles
targets.forEach((target, i) => {
gsap.to(target, {
y: 0,
opacity: 1, // ALWAYS 1 - CSS handles the rest!
duration: config.duration,
delay: i * config.stagger,
ease: config.ease
});
});
console.log(`đŦ Scroll animation #${index} started (${actualAnimateTarget})`);
}
});
}
});
});
console.log(`â
${elements.length} scroll-triggered animations ready`);
}
/**
* Initialize on-load animation
*/
function initOnLoadAnimation(config) {
const elements = document.querySelectorAll(config.selector);
if (!elements.length) {
console.log(`âšī¸ No elements found: "${config.selector}"`);
return;
}
elements.forEach((element, index) => {
const isJustified = hasJustifyAlignment(element);
// Override for justified text
let actualSplitType = config.type;
let actualAnimateTarget = config.animateTarget || 'lines';
if (isJustified && config.animateTarget === 'chars') {
console.warn(`â ī¸ On-load element with justify alignment detected. Using line animation.`);
actualSplitType = 'lines';
actualAnimateTarget = 'lines';
}
// Preserve star icon spans - prevent whitespace collapse
const starSpans = element.querySelectorAll('.header114_text-spacer');
starSpans.forEach(span => {
if (span.textContent.trim() === '') {
span.innerHTML = ''; // Zero-width space prevents SplitText from collapsing the span
}
});
// Create SplitText
SplitText.create(element, {
type: actualSplitType,
deepSlice: true,
aria: "auto",
autoSplit: true,
smartWrap: actualAnimateTarget === 'chars',
reduceWhiteSpace: !isJustified,
onSplit: function(self) {
// Determine what to animate
let targets = null;
if (actualAnimateTarget === 'chars' && self.chars && self.chars.length) {
targets = self.chars;
} else if (actualAnimateTarget === 'lines' && self.lines && self.lines.length) {
targets = self.lines;
} else if (self.words && self.words.length) {
targets = self.words;
} else if (self.lines && self.lines.length) {
targets = self.lines;
actualAnimateTarget = 'lines';
}
if (!targets || !targets.length) {
console.warn(`â ī¸ No valid targets found for on-load animation in element #${index}`);
return;
}
// Apply word wrapping CSS
if (actualAnimateTarget === 'chars' && self.words && self.words.length) {
self.words.forEach(word => {
word.style.whiteSpace = 'nowrap';
word.style.display = 'inline-block';
});
}
// For justified text with lines
if (isJustified && self.lines && self.lines.length) {
self.lines.forEach((line, lineIndex) => {
line.style.display = 'inline-block';
line.style.textAlign = 'justify';
// Justify all lines except the last one
if (lineIndex < self.lines.length - 1) {
line.style.textAlignLast = 'justify';
} else {
// Last line: left-aligned, not stretched
line.style.textAlignLast = 'start';
}
line.style.width = '100%';
});
element.style.textAlign = 'justify';
}
// Preserve star icon spans (must stay visible with correct styling)
const starSpans = element.querySelectorAll('.header114_text-spacer');
// Set initial state
gsap.set(targets, {
y: config.yOffset,
opacity: 0
});
// Keep star icons visible with their original CSS properties
if (starSpans.length) {
starSpans.forEach(span => {
gsap.set(span, {
opacity: 1,
y: 0,
width: '6.25rem',
maxWidth: '6.25rem',
display: 'inline-flex',
clearProps: 'transform,translate,rotate,scale'
});
});
}
// Handle empty lines (from
tags) - replace with actual BR tags
if (self.lines && self.lines.length > 1) {
const linesToRemove = [];
self.lines.forEach((line, i) => {
const lineText = line.textContent.trim();
// Empty line = was a
tag
if (lineText === '' || lineText === '\n') {
// Insert a real
tag BEFORE this empty line
const br = document.createElement('br');
line.parentNode.insertBefore(br, line);
// Mark this empty line for removal
linesToRemove.push(line);
}
});
// Remove empty line divs (they're no longer needed)
linesToRemove.forEach(line => {
line.remove();
});
}
// Create timeline
const tl = gsap.timeline({
delay: config.delay,
onComplete: () => {
console.log(`â
On-load animation #${index} completed`);
}
});
// Animate to opacity 1 - CSS handles muted styles
targets.forEach((target, i) => {
tl.to(target, {
y: 0,
opacity: 1, // ALWAYS 1 - CSS handles the rest!
duration: config.duration,
ease: config.ease
}, i * config.stagger);
});
return tl;
}
});
console.log(`đŦ On-load animation #${index} initialized`);
});
console.log(`â
${elements.length} on-load animations ready`);
}
/**
* Initialize trigger-based animations
* Uses data-animation-trigger and data-animation-target attributes
*/
function initTriggerAnimation() {
const targets = document.querySelectorAll('[data-animation-target]');
if (!targets.length) {
console.log('âšī¸ No trigger-based animation targets found');
return;
}
// Store split data by target name (not timelines - we create those with ScrollTrigger)
const splitData = {};
let pendingSplits = targets.length;
// Function to set up triggers AFTER all splits are complete
function setupTriggers() {
const triggers = document.querySelectorAll('[data-animation-trigger]');
if (!triggers.length) {
console.log('âšī¸ No animation triggers found');
return;
}
triggers.forEach((trigger, index) => {
const triggerName = trigger.getAttribute('data-animation-trigger');
const targetDataList = splitData[triggerName];
if (!targetDataList || !targetDataList.length) {
console.warn(`â ī¸ No target found for trigger "${triggerName}"`);
return;
}
// Create timeline with ScrollTrigger attached - GSAP handles play/reverse automatically
targetDataList.forEach((data, dataIndex) => {
const tl = gsap.timeline({
scrollTrigger: {
trigger: trigger,
start: 'top 80%',
end: 'bottom 20%',
toggleActions: 'play reverse play reverse'
}
});
// Add animations to timeline
data.targets.forEach((target, i) => {
tl.to(target, {
y: 0,
opacity: 1,
duration: data.config.duration,
ease: data.config.ease
}, i * data.config.stagger);
});
});
console.log(`đŦ Trigger "${triggerName}" #${index} initialized`);
});
console.log(`â
${triggers.length} animation triggers ready`);
}
targets.forEach((element, index) => {
const targetName = element.getAttribute('data-animation-target');
const animationType = element.getAttribute('data-animation-type') || 'lines';
const isJustified = hasJustifyAlignment(element);
// Determine split type based on animation type
let splitType = 'lines';
let animateTarget = 'lines';
let config = {
duration: 2.0,
stagger: 0.2,
yOffset: 30,
ease: 'power4.out'
};
if (animationType === 'chars') {
splitType = 'words,chars';
animateTarget = 'chars';
config = {
duration: 2.5,
stagger: 0.005,
yOffset: 80,
ease: 'power4.out'
};
}
// Override for justified text
if (isJustified && animationType === 'chars') {
console.warn(`â ī¸ Trigger target "${targetName}" has justify alignment. Switching to line animation.`);
splitType = 'lines';
animateTarget = 'lines';
config = {
duration: 2.0,
stagger: 0.2,
yOffset: 30,
ease: 'power4.out'
};
}
// Preserve star icon spans
const starSpans = element.querySelectorAll('.header114_text-spacer');
starSpans.forEach(span => {
if (span.textContent.trim() === '') {
span.innerHTML = '';
}
});
// Create SplitText
SplitText.create(element, {
type: splitType,
deepSlice: true,
aria: "auto",
autoSplit: false, // false to prevent resize re-split causing ScrollTrigger loop
smartWrap: animateTarget === 'chars',
reduceWhiteSpace: !isJustified,
onSplit: function(self) {
// Determine targets
let splitTargets = null;
if (animateTarget === 'chars' && self.chars && self.chars.length) {
splitTargets = self.chars;
} else if (animateTarget === 'lines' && self.lines && self.lines.length) {
splitTargets = self.lines;
} else if (self.lines && self.lines.length) {
splitTargets = self.lines;
}
if (!splitTargets || !splitTargets.length) {
console.warn(`â ī¸ No valid targets for trigger animation "${targetName}"`);
return;
}
// Apply word wrapping CSS
if (animateTarget === 'chars' && self.words && self.words.length) {
self.words.forEach(word => {
word.style.whiteSpace = 'nowrap';
word.style.display = 'inline-block';
});
}
// Handle justify alignment
if (isJustified && self.lines && self.lines.length) {
self.lines.forEach((line, lineIndex) => {
line.style.display = 'inline-block';
line.style.textAlign = 'justify';
if (lineIndex < self.lines.length - 1) {
line.style.textAlignLast = 'justify';
} else {
line.style.textAlignLast = 'start';
}
line.style.width = '100%';
});
element.style.textAlign = 'justify';
}
// Keep star icons visible
if (starSpans.length) {
starSpans.forEach(span => {
span.style.opacity = '1';
span.style.transform = 'none';
span.style.width = '6.25rem';
span.style.maxWidth = '6.25rem';
span.style.display = 'inline-flex';
span.style.backgroundImage = span.classList.contains('is-alternate')
? 'url(../images/white-4-point-star.webp)'
: 'url(../images/firstclass-living-star-20x20.svg)';
span.style.backgroundPosition = '0%';
span.style.backgroundRepeat = 'no-repeat';
span.style.backgroundSize = '20px';
});
}
// Handle empty lines (BR tags)
if (self.lines && self.lines.length > 1) {
const linesToRemove = [];
self.lines.forEach((line, i) => {
if (line.textContent.trim() === '' || line.textContent === '\n') {
const br = document.createElement('br');
line.parentNode.insertBefore(br, line);
linesToRemove.push(line);
}
});
linesToRemove.forEach(line => line.remove());
}
// Set initial state (hidden)
gsap.set(splitTargets, {
y: config.yOffset,
opacity: 0
});
// Store split data (timeline will be created in setupTriggers with ScrollTrigger)
if (!splitData[targetName]) {
splitData[targetName] = [];
}
splitData[targetName].push({
targets: splitTargets,
config: config
});
console.log(`đ¯ Trigger target "${targetName}" (${animateTarget}) ready`);
// Check if all splits are complete, then set up triggers
pendingSplits--;
if (pendingSplits === 0) {
setupTriggers();
}
}
});
});
}
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initGSAPTextReveal);
} else {
initGSAPTextReveal();
}
})();