BACK TO EXPERIMENTS

The sky above New Kyoto shimmered with the faint pulse of orbiting cargo lanes, each one lit like veins in a sleeping giant.

On the ground, Ava adjusted the filters on her respirator as the dust storm rolled in, thicker this time, like the planet was trying to hide something.

Her comm crackled to life...

“We found the signal again. It’s moving.”

“It’s moving!”

Falling Text

Inclusions
<link href="https://fonts.googleapis.com/css2?family=Crimson+Text:ital,wght@0,400;0,600;0,700;1,400;1,600;1,700&display=swap" rel="stylesheet">
    
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/ScrollTrigger.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.13.0/Physics2DPlugin.min.js"></script> 
CSS
<style>
    #scrolling-body { 
        margin: 0; padding: 0; overflow-x: hidden; 
        font-family: 'Crimson Text', serif;
        background-color: #F5F5DC; /* Initial: Light Tan */
        /* GSAP will handle transitions, but a base CSS transition can be a fallback */
        /* transition: background-color 0.4s ease-out; */
    }
    #scrolling-body p { 
        font-family: 'Crimson Text', serif;
    }
    #story-section { 
        width: 70%; margin: 0 auto; 
        color: #00008B; /* Initial: Dark Blue */
    }
    .sentence-container { 
        height: 100vh; display: flex; align-items: center; 
        justify-content: center; text-align: center; position: relative; 
    }
    p.sentence { 
        font-size: clamp(1.5rem, 2.5vw, 4rem); line-height: 1.5; max-width: 90%; 
        opacity: 1; white-space: pre-wrap; position: relative; text-align: center;
        color: inherit; /* Inherits from #story-section or body */
        /* transition: color 0.4s ease-out; */
    }
    #falling-letters-area { 
        position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; 
        pointer-events: none; z-index: 10; overflow: hidden; 
    }
    .falling-letter { 
        position: absolute; display: inline-block; 
        font-family: 'Crimson Text', serif; 
        will-change: transform, color; box-sizing: border-box; opacity: 1;
        /* Color will be set by JS based on state */
    }
</style>
JS
<script>
        document.addEventListener('DOMContentLoaded', function() {
            if (typeof gsap === 'undefined' || typeof ScrollTrigger === 'undefined' || typeof Physics2DPlugin === 'undefined') {
                console.error("A GSAP plugin is missing!"); return;
            }
            gsap.registerPlugin(ScrollTrigger, Physics2DPlugin);

            const fallingArea = document.getElementById('falling-letters-area');
            const sentenceStates = {}; 
            const bodyEl = document.getElementById('scrolling-body');
            const storySection = document.getElementById('story-section'); // Used for text color

            const downwardLook = { bg: '#F5F5DC', text: '#00008B' }; // Light Tan BG, Dark Blue Text
            const upwardLook = { bg: '#00008B', text: '#F5F5DC' };  // Dark Blue BG, Light Tan Text
            
            let currentGlobalLook = downwardLook; // Start with downward look

            // Tween to apply colors smoothly
            function transitionToLook(look, duration = 0.4) {
                if (bodyEl.style.backgroundColor !== look.bg || storySection.style.color !== look.text) {
                    gsap.to(bodyEl, { backgroundColor: look.bg, duration: duration, ease: "sine.out" });
                    // Target .sentence directly for text color to ensure it overrides inheritance if needed
                    gsap.to('.sentence', { color: look.text, duration: duration, ease: "sine.out" });
                    currentGlobalLook = look; // Update the global state
                }
            }

            function getLetterDataForFall(sentenceEl, sentenceId) { /* ... (same as before) ... */ 
                const letterDataArray = [];
                const originalStyles = { visibility: sentenceEl.style.visibility, opacity: sentenceEl.style.opacity };
                sentenceEl.style.visibility = 'hidden'; sentenceEl.style.opacity = '1';
                if(sentenceEl.parentElement) void sentenceEl.parentElement.offsetHeight;
                void sentenceEl.offsetHeight; 
                const originalHTML = sentenceEl.innerHTML;
                let tempHTML = ''; let letterIndex = 0;
                Array.from(sentenceEl.childNodes).forEach(node => { 
                    if (node.nodeType === Node.TEXT_NODE) {
                        node.textContent.split('').forEach(char => {
                            tempHTML += `<span class="measure-span" style="display:inline-block; position:relative;">${char}</span>`;
                        });
                    } else if (node.nodeName === 'BR') { tempHTML += '<br>'; } 
                    else { tempHTML += node.outerHTML || ''; }
                });
                sentenceEl.innerHTML = tempHTML;
                sentenceEl.querySelectorAll('.measure-span').forEach(span => {
                    const char = span.textContent;
                    if (char.trim() !== '' || char === ' ') {
                        const rect = span.getBoundingClientRect();
                        if (rect.width > 0 || char === ' ') {
                            letterDataArray.push({
                                char, id: `letter-${sentenceId}-fall-${letterIndex++}`,
                                rect: rect, width: rect.width, height: rect.height
                            });
                        }
                    }
                });
                sentenceEl.innerHTML = originalHTML;
                sentenceEl.style.visibility = originalStyles.visibility; 
                sentenceEl.style.opacity = originalStyles.opacity;
                return letterDataArray;
            }
            
            function createFallingLetterDiv(letterData, sentenceStyles, letterTextColor) { /* ... (same as before, takes letterTextColor) ... */
                const letterDiv = document.createElement('div');
                letterDiv.classList.add('falling-letter');
                letterDiv.id = letterData.id; 
                letterDiv.textContent = letterData.char;
                letterDiv.style.fontSize = sentenceStyles.fontSize;
                letterDiv.style.lineHeight = sentenceStyles.lineHeight;
                letterDiv.style.color = letterTextColor; // Apply specific color
                gsap.set(letterDiv, {
                    x: letterData.rect.left, y: letterData.rect.top,   
                    width: letterData.width > 0 ? letterData.width : 'auto',
                    opacity: 1 
                });
                fallingArea.appendChild(letterDiv);
                return letterDiv;
            }

            gsap.utils.toArray('.sentence').forEach((sentenceEl) => {
                const sentenceId = sentenceEl.dataset.sentenceId;
                sentenceStates[sentenceId] = {
                    letterDataForFall: null, 
                    fallingLetterDivs: [],
                    isFallen: false,
                    fallAnimationTweens: []
                    // No complex reform timeline needed, just state and fade
                };

                ScrollTrigger.create({
                    trigger: sentenceEl, 
                    start: "top top",
                    end: "bottom top", 
                    // markers: true,
                    onEnter: () => { // SENTENCE HITS TOP (SCROLLING DOWN) -> FALL
                        if (sentenceStates[sentenceId].isFallen) return;
                        sentenceStates[sentenceId].isFallen = true;
                        
                        transitionToLook(downwardLook); // Ensure downward look

                        gsap.to(sentenceEl, {opacity: 0, duration: 0.2, onComplete: () => {
                            gsap.set(sentenceEl, {visibility: 'hidden'});
                        }});

                        const sentenceComputedStyle = window.getComputedStyle(sentenceEl);
                        const sentenceFontSize = sentenceComputedStyle.fontSize;
                        let letterEffectiveHeight = parseFloat(sentenceComputedStyle.lineHeight === 'normal' ? parseFloat(sentenceFontSize) * 1.2 : sentenceComputedStyle.lineHeight);
                        if (isNaN(letterEffectiveHeight) || letterEffectiveHeight <= 0) letterEffectiveHeight = parseFloat(sentenceFontSize) * 1.2;
                        
                        const sentenceStyles = { fontSize: sentenceFontSize, lineHeight: letterEffectiveHeight + 'px' };
                        
                        if (!sentenceStates[sentenceId].letterDataForFall) {
                            sentenceStates[sentenceId].letterDataForFall = getLetterDataForFall(sentenceEl, sentenceId);
                        }
                        
                        sentenceStates[sentenceId].fallingLetterDivs.forEach(div => div.remove());
                        sentenceStates[sentenceId].fallingLetterDivs = [];
                        sentenceStates[sentenceId].fallAnimationTweens = [];

                        sentenceStates[sentenceId].letterDataForFall.forEach((data) => {
                            // Letters fall with the "downwardLook" text color
                            const letterDiv = createFallingLetterDiv(data, sentenceStyles, downwardLook.text);
                            sentenceStates[sentenceId].fallingLetterDivs.push(letterDiv);
                            const boundsFloor = window.innerHeight - letterEffectiveHeight;
                            const tween = gsap.to(letterDiv, {
                                physics2D: { /* ... physics params ... */ 
                                    gravity: gsap.utils.random(4000, 6000), angle: 270, 
                                    velocity: gsap.utils.random(200, 500), friction: 0.2, 
                                    bounds: { top:0,left:0,width:window.innerWidth,height:boundsFloor },
                                    restitution: gsap.utils.random(0.0, 0.1)
                                },
                                duration: gsap.utils.random(2.5, 4.5), ease: "power1.in",
                            });
                            sentenceStates[sentenceId].fallAnimationTweens.push(tween);
                        });
                    },
                    onLeaveBack: () => { // SENTENCE LEAVES TOP GOING UP -> REFORM
                        if (!sentenceStates[sentenceId].isFallen) return; 
                        sentenceStates[sentenceId].isFallen = false;

                        if (sentenceStates[sentenceId].fallAnimationTweens) {
                            sentenceStates[sentenceId].fallAnimationTweens.forEach(tween => tween.kill());
                            sentenceStates[sentenceId].fallAnimationTweens = [];
                        }
                        
                        transitionToLook(upwardLook); // Switch to upward look

                        // Fade out the fallen letters (they are already at the bottom)
                        // Their color is not changed here, they just disappear.
                        // The background change makes them effectively "inverse".
                        gsap.to(sentenceStates[sentenceId].fallingLetterDivs, { 
                            opacity: 0, 
                            duration: 0.3, // Faster fade out
                            delay: 0, // Start fading immediately as colors change
                            onComplete: () => {
                                sentenceStates[sentenceId].fallingLetterDivs.forEach(div => div.remove());
                                sentenceStates[sentenceId].fallingLetterDivs = [];
                            }
                        });
                        
                        // Fade in the original sentence. It will inherit the new text color from .sentence
                        // which was updated by transitionToLook.
                        gsap.set(sentenceEl, {
                            color: upwardLook.text, // Explicitly set color before fade for safety
                            visibility: 'visible'
                        }); 
                        gsap.to(sentenceEl, { 
                            opacity: 1, 
                            duration: 0.4, 
                            delay: 0.1 // Delay slightly for BG change to start
                        }); 
                    }
                });
            });

            // Optional: A global ScrollTrigger to detect scroll direction for proactive color change.
            // This is more advanced and might be overly sensitive. The onEnter/onLeaveBack might be enough.
            // let lastScrollTop = 0;
            // ScrollTrigger.create({
            //     trigger: document.documentElement, // or window
            //     start: "top top",
            //     end: "bottom bottom",
            //     onUpdate: (self) => {
            //         let st = self.scrollTop;
            //         if (st > lastScrollTop) { // Scrolling Down
            //             if (currentGlobalLook !== downwardLook) {
            //                 // console.log("Global down, setting downwardLook");
            //                 // transitionToLook(downwardLook, 0.2); // Faster transition
            //             }
            //         } else { // Scrolling Up
            //             if (currentGlobalLook !== upwardLook) {
            //                 // console.log("Global up, setting upwardLook");
            //                 // transitionToLook(upwardLook, 0.2);
            //             }
            //         }
            //         lastScrollTop = st <= 0 ? 0 : st; // For Mobile or negative scrolling
            //     }
            // });


            window.addEventListener('resize', () => { 
                ScrollTrigger.refresh(); 
                for (const id in sentenceStates) {
                    sentenceStates[id].letterDataForFall = null; 
                }
            });
        });
    </script>