// animations.jsx — GSAP + ScrollTrigger premium scroll animation system // Requires: gsap.min.js + ScrollTrigger.min.js loaded before this file // Called from HomePage via window.initGSAPAnimations() (function buildAnimationsModule() { // ─── Reduced motion guard ───────────────────────────────────────────────── const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; // ─── Plugin registration ────────────────────────────────────────────────── function ensureGSAP() { if (!window.gsap || !window.ScrollTrigger) { console.warn('[animations] GSAP or ScrollTrigger not found — animations skipped.'); return false; } window.gsap.registerPlugin(window.ScrollTrigger); return true; } // ─── Utility: safe querySelectorAll ────────────────────────────────────── function $$(sel, ctx) { return Array.from((ctx || document).querySelectorAll(sel)); } // ───────────────────────────────────────────────────────────────────────── // 1. HERO — blob multi-speed parallax + content cinematic scroll-out // No conflict: aurora-blob and aurora-content are not .reveal elements // Replaces the manual window.scroll listener previously in AuroraHero // ───────────────────────────────────────────────────────────────────────── function animateHero(gsap, ScrollTrigger) { const hero = document.querySelector('.aurora-hero'); if (!hero) return; const content = hero.querySelector('.aurora-content'); const blobs = hero.querySelectorAll('.aurora-blob'); const hFooter = hero.querySelector('.aurora-footer'); // ── Blobs: three independent parallax speeds ────────────────────────── // Each blob drifts at a different rate, amplifying the depth illusion. const blobSpeeds = [110, -70, 160]; // px to travel over full hero scroll distance blobs.forEach((blob, i) => { gsap.to(blob, { y: blobSpeeds[i], ease: 'none', scrollTrigger: { trigger: hero, start: 'top top', end: 'bottom top', scrub: 2.8, invalidateOnRefresh: true, }, }); }); // ── Content: parallax up + opacity fade as hero exits ───────────────── // Starts after 15% of hero has scrolled; finishes when hero is fully gone. if (content) { gsap.to(content, { y: () => -(window.innerHeight * 0.28), opacity: 0, ease: 'power1.in', scrollTrigger: { trigger: hero, start: '15% top', end: 'bottom top', scrub: 1.6, invalidateOnRefresh: true, }, }); } // ── Footer scroll cue: fade early so it doesn't linger ─────────────── if (hFooter) { gsap.to(hFooter, { opacity: 0, ease: 'none', scrollTrigger: { trigger: hero, start: '8% top', end: '22% top', scrub: 1, }, }); } } // ───────────────────────────────────────────────────────────────────────── // 2. CAPABILITIES STACK — clip-path wipe, alternating left/right direction // .svc-stack-item has .reveal class, so we use clipPath (safe, no conflict). // ───────────────────────────────────────────────────────────────────────── function animateCapabilities(gsap) { $$('.svc-stack-item').forEach((item, i) => { const fromLeft = i % 2 === 0; gsap.fromTo( item, { clipPath: fromLeft ? 'inset(0 100% 0 0)' : 'inset(0 0 0 100%)' }, { clipPath: 'inset(0 0% 0 0%)', duration: 1.0, ease: 'expo.out', // immediateRender keeps initial state even before trigger fires immediateRender: true, scrollTrigger: { trigger: item, start: 'top 90%', }, } ); }); } // ───────────────────────────────────────────────────────────────────────── // 3. WORK LIST — thumbnail clip-path wipe from bottom // .wl-thumb is NOT a .reveal element — fully safe. // Note: the img itself is NOT GSAP-animated to preserve the CSS hover scale // (`.wl-item:hover .wl-thumb img { transform: scale(1.07) }`) — a GSAP // scrub on the img would set inline transform and break that hover state. // ───────────────────────────────────────────────────────────────────────── function animateWorkItems(gsap, ScrollTrigger) { $$('.wl-thumb').forEach((thumb) => { const item = thumb.closest('.wl-item') || thumb.parentElement; // Thumbnail: clip-path wipe from bottom — reveals as item enters viewport gsap.fromTo( thumb, { clipPath: 'inset(100% 0 0 0)' }, { clipPath: 'inset(0% 0 0 0)', duration: 0.95, ease: 'power3.out', immediateRender: true, scrollTrigger: { trigger: item, start: 'top 88%', }, } ); }); } // ───────────────────────────────────────────────────────────────────────── // 4. PROCESS — step numbers slide in + accent timeline line draws itself // .step has .reveal class → only use `x` on children (.step-num). // Drawing line uses a dynamically inserted element — no conflict. // ───────────────────────────────────────────────────────────────────────── function animateProcess(gsap, ScrollTrigger) { // Step numbers slide in from the left (x only — no opacity, avoids reveal clash) $$('.step-num').forEach((num, i) => { gsap.fromTo( num, { x: -50, scale: 0.8 }, { x: 0, scale: 1, duration: 1.0, ease: 'expo.out', immediateRender: true, scrollTrigger: { trigger: num.closest('.step') || num, start: 'top 88%', }, } ); }); // Accent line that draws across the top of the process steps grid. // Guard against duplicate inserts on route-change remounts. const stepsGrid = document.querySelector('.process-steps'); if (stepsGrid) { const existing = stepsGrid.parentElement.querySelector('.gsap-process-line'); if (existing) existing.remove(); const line = document.createElement('div'); line.className = 'gsap-process-line'; stepsGrid.parentElement.insertBefore(line, stepsGrid); gsap.fromTo( line, { scaleX: 0 }, { scaleX: 1, ease: 'none', scrollTrigger: { trigger: stepsGrid, start: 'top 78%', end: 'top 30%', scrub: 1.2, invalidateOnRefresh: true, }, } ); } } // ───────────────────────────────────────────────────────────────────────── // 5. METRICS — skipped intentionally. // .mt-cell already transitions opacity+transform via seen-state CSS. // Adding GSAP transform on the same element would conflict with the CSS. // The existing CountUp + CSS stagger already creates a premium reveal. // ───────────────────────────────────────────────────────────────────────── // ───────────────────────────────────────────────────────────────────────── // 6. CTA BAND — cinematic scale entrance + wordmark subtle parallax // .cta-band itself is NOT a .reveal element — full freedom. // h2 and btns are children of inner .reveal but properties won't clash. // ───────────────────────────────────────────────────────────────────────── function animateCTA(gsap) { const cta = document.querySelector('.cta-band'); if (!cta) return; // Entire section: scale from 0.95 → 1 on entry for cinematic feel gsap.fromTo( cta, { scale: 0.95 }, { scale: 1, duration: 1.4, ease: 'expo.out', immediateRender: true, scrollTrigger: { trigger: cta, start: 'top 85%', }, } ); // h2: rises from below with slight scale. // Trigger at top 92% to fire ≈ same time as IntersectionObserver reveal // on the parent .reveal div, so the two effects layer without a gap. const h2 = cta.querySelector('h2'); if (h2) { gsap.fromTo( h2, { y: 60, scale: 0.94 }, { y: 0, scale: 1, duration: 1.3, ease: 'expo.out', immediateRender: true, scrollTrigger: { trigger: cta, start: 'top 92%', }, } ); } // Wordmark: subtle clockwise rotation on scroll const wmImg = cta.querySelector('.wordmark img'); if (wmImg) { gsap.fromTo( wmImg, { rotation: -2, scale: 0.95 }, { rotation: 1, scale: 1, ease: 'none', immediateRender: true, scrollTrigger: { trigger: cta, start: 'top bottom', end: 'bottom top', scrub: 2, invalidateOnRefresh: true, }, } ); } } // ───────────────────────────────────────────────────────────────────────── // 7. SECTION DIVIDERS / STRIP — subtle scale on the client strip // .strip is a separate, non-reveal element — safe to animate. // ───────────────────────────────────────────────────────────────────────── function animateStrip(gsap) { const strip = document.querySelector('.strip'); if (!strip) return; gsap.fromTo( strip, { opacity: 0, y: 20 }, { opacity: 1, y: 0, duration: 0.9, ease: 'power2.out', immediateRender: true, scrollTrigger: { trigger: strip, start: 'top 92%', }, } ); } // ───────────────────────────────────────────────────────────────────────── // 8. TESTIMONIALS — slider container subtle scale + depth // ───────────────────────────────────────────────────────────────────────── function animateTestimonials(gsap) { const slider = document.querySelector('.t-slider'); if (!slider) return; gsap.fromTo( slider, { scale: 0.96, opacity: 0 }, { scale: 1, opacity: 1, duration: 1.1, ease: 'power3.out', immediateRender: true, scrollTrigger: { trigger: slider, start: 'top 82%', }, } ); } // ───────────────────────────────────────────────────────────────────────── // 9. FAQ — skipped intentionally. // .faq-item lives inside .faq-list.reveal; adding a GSAP opacity/y on // children while the parent IO reveal also fires creates a visible race. // The FAQ accordion's own CSS transitions are already clean. // ───────────────────────────────────────────────────────────────────────── // ───────────────────────────────────────────────────────────────────────── // 10. SCROLL DEPTH EFFECTS — hero letter-spacing breathes on scroll // Gives the large headline kinetic life as the user starts scrolling. // ───────────────────────────────────────────────────────────────────────── function animateHeroTypography(gsap, ScrollTrigger) { const h1 = document.querySelector('.aurora-h1'); if (!h1) return; // Letter-spacing contracts as you scroll (feels like zooming in) gsap.to(h1, { letterSpacing: '-0.05em', ease: 'none', scrollTrigger: { trigger: '.aurora-hero', start: 'top top', end: '45% top', scrub: 1.2, }, }); } // ───────────────────────────────────────────────────────────────────────── // 11. LAYERED DEPTH — sections scale slightly as they enter mid-viewport // Creates a subtle "coming into focus" feel between sections. // Targets non-reveal containers only (process, strip, metrics sections). // ───────────────────────────────────────────────────────────────────────── function animateSectionDepth(gsap, ScrollTrigger) { // Process section container const processSection = document.querySelector('.process'); if (processSection) { gsap.fromTo( processSection, { scale: 0.97 }, { scale: 1, duration: 1.2, ease: 'power2.out', immediateRender: true, scrollTrigger: { trigger: processSection, start: 'top 85%', }, } ); } } // ───────────────────────────────────────────────────────────────────────── // 12. WORK SECTION HEADER — wl-head slides in from left // .wl-head is wrapped in but wl-head-label and wl-head-count // are direct children — use x movement on those (no opacity conflict). // ───────────────────────────────────────────────────────────────────────── function animateWorkHeader(gsap) { const label = document.querySelector('.wl-head-label'); const count = document.querySelector('.wl-head-count'); if (label) { gsap.fromTo(label, { x: -30 }, { x: 0, duration: 0.8, ease: 'power2.out', immediateRender: true, scrollTrigger: { trigger: label.closest('.wl-head') || label, start: 'top 90%' }, } ); } if (count) { gsap.fromTo(count, { x: 30 }, { x: 0, duration: 0.8, ease: 'power2.out', immediateRender: true, scrollTrigger: { trigger: count.closest('.wl-head') || count, start: 'top 90%' }, } ); } } // ───────────────────────────────────────────────────────────────────────── // 13. PAGE HEAD — inner-page hero: eyebrow slides from left, h1 rises, // deck fades in. Delay-based (no ScrollTrigger) — always in viewport. // h1 uses `y` only (no opacity) so TypeReveal char animation is unaffected. // ───────────────────────────────────────────────────────────────────────── function animatePageHead(gsap) { const head = document.querySelector('.page-head'); if (!head) return; const eyebrow = head.querySelector('.eyebrow'); const h1 = head.querySelector('h1'); const deck = head.querySelector('.deck'); if (eyebrow) { gsap.fromTo(eyebrow, { x: -24, opacity: 0 }, { x: 0, opacity: 1, duration: 0.7, delay: 0.05, ease: 'power2.out', immediateRender: true }); } if (h1) { gsap.fromTo(h1, { y: 50 }, { y: 0, duration: 1.0, delay: 0.1, ease: 'expo.out', immediateRender: true }); } if (deck) { gsap.fromTo(deck, { y: 28, opacity: 0 }, { y: 0, opacity: 1, duration: 0.9, delay: 0.3, ease: 'power3.out', immediateRender: true }); } } // ───────────────────────────────────────────────────────────────────────── // 14. CASE STUDY HERO — section scale entrance + breadcrumb slide-in. // CaseStudyTitle manages its own word-reveal via IntersectionObserver; // cs-deck is inside LineReveal; cs-meta cells are .reveal — all skipped. // ───────────────────────────────────────────────────────────────────────── function animateCaseStudyHero(gsap) { const hero = document.querySelector('.cs-hero'); if (!hero) return; gsap.fromTo(hero, { scale: 0.97 }, { scale: 1, duration: 1.2, ease: 'expo.out', immediateRender: true }); const breadcrumb = hero.querySelector('.cs-breadcrumb'); if (breadcrumb) { gsap.fromTo(breadcrumb, { x: -24, opacity: 0 }, { x: 0, opacity: 1, duration: 0.7, delay: 0.1, ease: 'power2.out', immediateRender: true }); } } // ───────────────────────────────────────────────────────────────────────── // 15. WORK GRID — .work-card clip-path wipe from bottom on scroll entry. // .work-card has .reveal (opacity/transform via IO CSS), so clipPath // is the only safe GSAP property to use here. // ───────────────────────────────────────────────────────────────────────── function animateWorkGrid(gsap) { $$('.work-card').forEach((card) => { gsap.fromTo( card, { clipPath: 'inset(0 0 100% 0)' }, { clipPath: 'inset(0 0 0% 0)', duration: 0.9, ease: 'expo.out', immediateRender: true, scrollTrigger: { trigger: card, start: 'top 88%', }, } ); }); } // ───────────────────────────────────────────────────────────────────────── // MAIN INITIALIZER — exported to window, called by App useEffect on [route] // ───────────────────────────────────────────────────────────────────────── function initGSAPAnimations() { // Bail on reduced motion — respect user preference if (prefersReducedMotion) return () => {}; if (!ensureGSAP()) return () => {}; const { gsap, ScrollTrigger } = window; // Kill any leftover ScrollTriggers from prior mount (route navigation) ScrollTrigger.getAll().forEach(t => t.kill()); // Configure ScrollTrigger for performance ScrollTrigger.config({ limitCallbacks: true, syncInterval: 40 }); // ── Run all animation groups ────────────────────────────────────────── animateHero(gsap, ScrollTrigger); // blobs + content scroll-out animateHeroTypography(gsap, ScrollTrigger); // letter-spacing scrub animateCapabilities(gsap); // clip-path wipe alternating animateWorkItems(gsap, ScrollTrigger); // thumb wipe + image parallax animateWorkHeader(gsap); // label/count slide from sides animateProcess(gsap, ScrollTrigger); // step-num slide + accent line animateCTA(gsap); // section scale + h2 rise animateStrip(gsap); // client strip entrance animateTestimonials(gsap); // slider depth entrance animateSectionDepth(gsap, ScrollTrigger); // process section scale-in // ── Inner page animations (silently no-op on home) ──────────────────── animatePageHead(gsap); // inner page hero entrance animateCaseStudyHero(gsap); // case study hero scale + breadcrumb animateWorkGrid(gsap); // work page grid clip-path wipe // Recalculate positions after all triggers are set ScrollTrigger.refresh(); // Cleanup function returned so App useEffect can call it on route change return function cleanup() { ScrollTrigger.getAll().forEach(t => t.kill()); }; } // Expose to React components window.initGSAPAnimations = initGSAPAnimations; })(); // end module IIFE