/** * 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(); } })();