Hi guys!
I come humbly in front of you with few drops of hope left, after 5 full days of switching between possible solutions to get a consistent ScrollTrigger behavior on a Gatsby site. Getting directly to you is my last resort, as every google and gsap forum link regarding ScrollTrigger and Gatsby is already visited. ?
I cannot get a CodePen reproducing the exact issue so I'll try my best to describe it here.
Shortly, the problem seems to be, as I suspect, that the ScrollTrigger does not refresh itself when Javascript pops into the browser on top of the SSR-ed html/css bundle.
Here's what i did.
I created several projects with different versions for dependencies, but i will stick to the simplest one with all dependencies up to date.It's a gatsby with material-ui plugin added, who's exact structure can be found here: https://github.com/mui-org/material-ui/tree/master/examples/gatsby
There are no other plugins added, nor any other configs/plugins changed.
I rendered the component that will contain the ScrollTrigger (AboutBlock) in the AboutPage page:
about.js
const AboutPage = () => {
return (
<AboutBlock />
)
}
export default AboutPage
This is the component where i try to animate some elements on reveal when scrolled into view:
aboutBlock.js
import gsap from "gsap";
import ScrollTrigger from 'gsap/ScrollTrigger';
import animateReveal from "./gs_reveal";
export default function AboutBlock() {
gsap.registerPlugin(ScrollTrigger)
const revealRefs = useRef([])
revealRefs.current = []
useLayoutEffect(() => {
let scrollTriggers = []
scrollTriggers = animateReveal(revealRefs.current)
return () => {
scrollTriggers.forEach(t => t.kill(true))
}
}, []);
const addToRevealRefs = el => {
if (el && !revealRefs.current.includes(el)) {
revealRefs.current.push(el);
}
};
return (
<Grid container>
<Grid item
width={{ xs: '100%', sm: '80%', md: '35%' }}
pl={{ xs: 0, md: '2.5%' }}
mt={{ xs: 60, sm: 0 }}>
<Grid container direction="column"
alignItems={{ xs: "flex-start", sm: "flex-end" }}>
<Grid item mt={{ xs: 0, md: '10vh' }} id="acum">
<Typography variant="h5" textAlign={{ xs: "left", sm: "right" }}
ref={addToRevealRefs}
className='gs_reveal_fromRight'>
NOW WE ARE IN
</Typography>
</Grid>
<Grid item>
<Typography variant="h6" textAlign={{ xs: "left", sm: "right" }}
ref={addToRevealRefs}
className='gs_reveal_fromRight'>
LOCATION
</Typography>
</Grid>
<Grid item mt="10vh" id="hi">
<Typography variant="h5" textAlign={{ xs: "left", sm: "right" }}
ref={addToRevealRefs}
className='gs_reveal_fromRight'>
SAY HI
</Typography>
</Grid>
<Grid item className='toughts'>
<Typography variant="h6" textAlign={{ xs: "left", sm: "right" }}
ref={addToRevealRefs}
className='gs_reveal_fromRight'>
TELL US YOUR THOUGHTS
</Typography>
</Grid>
</Grid>
</Grid>
</Grid>
}
HTML is longer and crowded, I left a part to get the idea of the structure and styling approach (MUI's sx - emotion).
And finally, this is the animateReveal function:
gs_reveal.js
import ScrollTrigger from 'gsap/ScrollTrigger';
import gsap from 'gsap';
export default function animateReveal(elements) {
const triggers = []
elements.forEach(function (elem) {
hide(elem)
let tr = ScrollTrigger.create({
trigger: elem,
id: elem.id,
end: 'bottom top',
markers: true,
onEnter: function () { animateFrom(elem) },
onEnterBack: function () { animateFrom(elem, -1) },
onLeave: function () { hide(elem) }
});
triggers.push(tr)
});
return triggers;
}
function animateFrom(elem, direction) {
direction = direction || 1;
let x = 0,
y = direction * 100;
if (elem.classList.contains("gs_reveal_fromLeft")) {
x = -100;
y = 0;
} else if (elem.classList.contains("gs_reveal_fromRight")) {
x = 100;
y = 0;
}
else if (elem.classList.contains("gs_reveal_fromBelow")) {
y = -100
}
elem.style.transform = "translate(" + x + "px, " + y + "px)";
elem.style.opacity = "0";
gsap.fromTo(elem, { x: x, y: y, autoAlpha: 0 }, {
duration: 1.25,
x: 0,
y: 0,
autoAlpha: 1,
ease: "expo",
overwrite: "auto",
delay: elem.classList.contains("gs_delay") ? 0.3 : 0,
});
}
function hide(elem) {
gsap.set(elem, { autoAlpha: 0 });
}
The ScrollTrigger markers are misplaced when page loads, and might move (get more misplaced) on hard reloading page, depending on the current scroll position in the moment of reloading, even though the scroll position is not preserved on reload (always is scrolled on top). - The markers are placed on the correct position on resizing, as expected.
I followed gsap official docs on react and react-advanced and tried:
grabbing the html elements to animate on scroll inside animateReveal() by
let elements = gsap.utils.toArray(".gs_reveal");
Assigning to each element a useRef() and use the .current value for each in animateReveal()
grabbing html elements using gsap's selector utility gsap.utils.selector
changing to simpler animation on scroll, like just a fade
refreshing ScrollTrigger in different moments
useLayoutEffect(() => {
ScrollTrigger.refresh(true) // or ScrollTrigger.refresh()
...
}, []);
6. Lifting ScrollTrigger logic to parent about.js page
7. Assigning scrollTrigger to a timeline triggered by the to-be-reveal element
8. Use useEffect() instead of useLayoutEffect() (recommended anyway for ScrollTrigger)
7. Other who-knows-what unsuccessful twists.
I suspected a rehydration error, when the static generated code does not match the client side one. But the only JS that could cause a mismatch is the gsap related one, and it does not seem an SSR issue. I checked if the CSS and HTML elements are being properly SSR-ed, by preventing JS from running in the browser. All looking fine.
This is both a SSR issue (gatsby build) and a development issue (no SSR).
As i said on point 5, setting a ScrollTrigger.refresh() when component is mounted does not work, but delaying this with a 1-2 seconds in a setTimeout successfully solves the issue
useLayoutEffect(() => {
setTimeout(() => {
ScrollTrigger.refresh(true)
}, 2000);
}, []);
This is hard to be accepted as a solution, since i cannot rely on a fixed value to 'guess' when DOM is properly rendered in the eyes of the ScrollTrigger, not to mention the glitches that might occur.
So, the question is 'WHY?', why animating with ScrollTrigger from within useLayoutEffect, which is not triggered on the server anyway and should mark the 'component is successfully mounted' moment, seems to not wait for the DOM being completely painted, even though nothing is generated dynamically!
There are quite of threads on this forum regarding gatsby, and none seemed to have a clear cause-outcome-solution.
Is this battle lost, should i move on? Do you have any suggestions?
Thanks so much for your time reading this, it means so much to me!