/** * Page Transition Controller using Barba.js and GSAP * Handles smooth image expansion transitions from list cards to detail hero * * Required HTML attributes: * - data-barba="wrapper" on body or main wrapper element * - data-barba="container" on main content wrapper (each page) * - data-barba-namespace="list" on list page container * - data-barba-namespace="detail" on detail page container * - r-transition-card on clickable card element (usually an tag) * - r-transition-img="source" on card image * - r-transition-img="target" on detail hero image * * External dependencies (load via CDN before this script): * - Barba.js: https://unpkg.com/@barba/core * - GSAP: loaded via Webflow * * IMPORTANT: The History API protection script must be loaded in the * BEFORE any other scripts. See postmortem-barba-eloqua-conflict.md for details. */ (() => { /* -------------------------------------------------- CONFIG -------------------------------------------------- */ const TRANSITIONCONFIG = { selectors: { transitionCard: "[r-transition-card]", sourceImg: '[r-transition-img="source"]', sourceImgElement: '[r-card-hover-effect="target-img"]', sourceHide: "[r-transition-card-hide]", targetImg: '[r-transition-img="target"]', targetFade: '[r-transition-hero="fade-in"]', targetBlock: '[r-transition-hero="block"]', }, animation: { imageDuration: 0.8, imageEase: "power3.inOut", fadeDuration: 0.5, fadeEase: "ease", // Entry animations for detail page blockReveal: { duration: 0.6, ease: "power2.inOut", }, fadeIn: { duration: 0.3, ease: "ease", yOffset: 40, stagger: 0.1, }, }, classes: { transitioning: "cc-is-transitioning", }, }; /* -------------------------------------------------- STATE -------------------------------------------------- */ // Store clicked card data let clickedImgData = null; /* -------------------------------------------------- HELPER FUNCTIONS -------------------------------------------------- */ /** * Hide elements within the clicked card that should not be visible during transition * @param {HTMLElement} card - The clicked card element */ function hideCardElements(card) { const elementsToHide = card.querySelectorAll( TRANSITIONCONFIG.selectors.sourceHide, ); if (!elementsToHide.length) return; gsap.to(elementsToHide, { opacity: 0, duration: 0.2, ease: TRANSITIONCONFIG.animation.fadeEase, }); } /** * Creates a clone of the source image and positions it fixed on screen * @param {HTMLElement} sourceImg - The source image element * @returns {HTMLElement} The cloned image element */ function createFloatingImage(sourceImg) { const rect = sourceImg.getBoundingClientRect(); const clone = sourceImg.cloneNode(true); // Get computed styles to preserve object-fit behavior const computedStyle = window.getComputedStyle(sourceImg); clone.classList.add("floating-image"); gsap.set(clone, { position: "fixed", top: rect.top, left: rect.left, width: rect.width, height: rect.height, margin: 0, zIndex: 1100, objectFit: computedStyle.objectFit || "cover", objectPosition: computedStyle.objectPosition || "center", }); gsap.set(clone.querySelector(TRANSITIONCONFIG.selectors.sourceImgElement), { scale: 1, }); document.body.appendChild(clone); return clone; } /** * Animate floating image from source position to target position * @param {HTMLElement} floatingImg - The floating image clone * @param {DOMRect} targetRect - The target position rectangle * @returns {gsap.core.Tween} The animation tween */ function animateImageToTarget(floatingImg, targetRect) { return gsap.to(floatingImg, { borderRadius: 0, top: 0, left: targetRect.x, width: targetRect.width, height: targetRect.height, duration: TRANSITIONCONFIG.animation.imageDuration, ease: TRANSITIONCONFIG.animation.imageEase, }); } /** * Default fade out animation for leaving page * @param {HTMLElement} container - The container to animate * @param {number} delay - Optional delay before animation * @returns {gsap.core.Tween} The animation tween */ function fadeOut(container, delay) { return gsap.fromTo( container, { opacity: 1, }, { opacity: 0, delay: delay || 0, duration: TRANSITIONCONFIG.animation.fadeDuration, ease: TRANSITIONCONFIG.animation.fadeEase, overwrite: true, }, ); } /** * Default fade in animation for entering page * @param {HTMLElement} container - The container to animate * @param {number} delay - Optional delay before animation * @returns {gsap.core.Tween} The animation tween */ function fadeIn(container, delay) { return gsap.fromTo( container, { opacity: 0, }, { opacity: 1, delay: delay || 0, duration: TRANSITIONCONFIG.animation.fadeDuration, ease: TRANSITIONCONFIG.animation.fadeEase, overwrite: true, }, ); } /** * Animate entry elements on detail page * First: targetBlock with clipPath reveal (right to left) * Then: targetFade elements with fade in + Y offset with stagger * @param {HTMLElement} container - The page container * @returns {gsap.core.Timeline} The animation timeline */ function animateEntryElements(container) { const targetBlock = container.querySelector( TRANSITIONCONFIG.selectors.targetBlock, ); const targetFadeElements = container.querySelectorAll( TRANSITIONCONFIG.selectors.targetFade, ); const tl = gsap.timeline(); // ClipPath animation for targetBlock (right to left reveal) if (targetBlock) { // Set initial state: hidden (clipped from left) gsap.set(targetBlock, { clipPath: "inset(0 0 0 100%)", }); tl.to(targetBlock, { clipPath: "inset(0 0 0 0%)", duration: TRANSITIONCONFIG.animation.blockReveal.duration, ease: TRANSITIONCONFIG.animation.blockReveal.ease, }); } // Fade in animation for targetFade elements with stagger if (targetFadeElements.length > 0) { // Set initial state gsap.set(targetFadeElements, { opacity: 0, y: TRANSITIONCONFIG.animation.fadeIn.yOffset, }); tl.to(targetFadeElements, { opacity: 1, y: 0, duration: TRANSITIONCONFIG.animation.fadeIn.duration, ease: TRANSITIONCONFIG.animation.fadeIn.ease, stagger: TRANSITIONCONFIG.animation.fadeIn.stagger, }); } return tl; } /** * Refresh scripts marked with [refresh] attribute */ function refreshScripts() { document.body.querySelectorAll("script[refresh]").forEach((script) => { const src = script.src; script.remove(); const newScript = document.createElement("script"); newScript.src = src; newScript.setAttribute("refresh", "true"); document.body.appendChild(newScript); }); } /** * Run entry animations when directly accessing or refreshing the detail page */ function runInitialEntryAnimations() { const container = document.querySelector('[data-barba-namespace="detail"]'); if (!container) return; // Run entry animations for detail page animateEntryElements(container); } /* -------------------------------------------------- MAIN INITIALIZATION -------------------------------------------------- */ /** * Initialize Barba.js page transitions */ function initPageTransitions() { // Guard clause: check if Barba wrapper exists const wrapper = document.querySelector("[data-barba='wrapper']"); if (!wrapper) return; // Track floating image for cleanup let floatingImg = null; barba.init({ // Disable cache to ensure fresh DOM for entry animations cacheIgnore: true, transitions: [ // Image expansion transition: list -> detail { name: "card-to-hero", from: { namespace: ["list"] }, to: { namespace: ["detail"] }, leave(data) { // Guard: if no clicked image data, just fade out if (!clickedImgData) { return fadeOut(data.current.container); } // Create a floating clone before fading out the container floatingImg = createFloatingImage(clickedImgData.element); hideCardElements(floatingImg); // Fade out the current container (floating image stays visible) return fadeOut(data.current.container, 0.2); }, enter(data) { // Find target image in new container const targetImg = data.next.container.querySelector( TRANSITIONCONFIG.selectors.targetImg, ); // Hide new container initially gsap.set(data.next.container, { opacity: 0 }); // Guard: if no floating image or target, just fade in (no entry animations) if (!clickedImgData || !targetImg || !floatingImg) { if (floatingImg) { floatingImg.remove(); floatingImg = null; } fadeIn(data.next.container); animateEntryElements(data.next.container); return; } // Hide target image (will be revealed after animation) gsap.set(targetImg, { visibility: "hidden" }); // Get target position const targetRect = targetImg.getBoundingClientRect(); // Animate floating image to target position return animateImageToTarget(floatingImg, targetRect).then(() => { // Reveal target image gsap.set(targetImg, { visibility: "visible" }); // Fade in new container fadeIn(data.next.container).then(() => { // Remove floating clone floatingImg.remove(); floatingImg = null; // Animate entry elements only for card transitions animateEntryElements(data.next.container); }); }); }, }, // Default fade transition for all other navigations { name: "default-fade", leave(data) { return fadeOut(data.current.container); }, beforeEnter() { return refreshScripts(); }, enter(data) { fadeIn(data.next.container); }, }, ], }); // Capture clicked image data before navigation starts barba.hooks.before((data) => { document.body.classList.add(TRANSITIONCONFIG.classes.transitioning); // Reset state clickedImgData = null; // Check if navigation was triggered by a card click if (!data.trigger || !data.trigger.closest) return; const card = data.trigger.closest( TRANSITIONCONFIG.selectors.transitionCard, ); if (!card) return; const sourceImg = card.querySelector(TRANSITIONCONFIG.selectors.sourceImg); if (!sourceImg) return; clickedImgData = { element: sourceImg, rect: sourceImg.getBoundingClientRect(), }; }); barba.hooks.after(() => { document.body.classList.remove(TRANSITIONCONFIG.classes.transitioning); // Reset scroll position window.scrollTo(0, 0); }); // Run entry animations on initial page load (direct access or refresh) runInitialEntryAnimations(); } /* -------------------------------------------------- BOOTSTRAP -------------------------------------------------- */ // Initialize when DOM is ready (handles both fresh load and refresh) if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", initPageTransitions); } else { initPageTransitions(); } })();