Hydration Before Animation
Why GSAP can create React hydration mismatches in Next.js, and how to design an animation lifecycle that waits for the DOM without making the page feel late.
Animation bugs are usually visual.
Hydration bugs are stranger because the page can look correct while React is telling you that the DOM it received is not the DOM it rendered.
In a motion-heavy Next.js application, the two problems can meet in an uncomfortable way: GSAP mutates an element before React has finished hydrating it.
The result is a warning that looks something like this:
A tree hydrated but some attributes of the server rendered HTML
didn't match the client properties.Then the diff shows inline styles that exist on one side but not the other:
style="transform: translate3d(...); opacity: 0; visibility: hidden"That is a lifecycle problem, not a styling problem.
What hydration expects
With server rendering, React receives HTML that was generated before the browser had access to real client state.
The client then runs the same component tree and expects the initial render to match that HTML closely enough to attach behavior safely.
If a third-party system mutates the DOM during that window, React can observe a different tree.
This is especially easy with a global interaction engine that scans the document for attributes such as:
[data-tilt]
[data-reveal]
[data-parallax]and immediately calls gsap.set() or creates timelines.
The animation engine is doing exactly what it was asked to do. It just started too early.
Local animation is easier to reason about than global animation
A component-scoped animation has a natural lifecycle:
function Card() {
const ref = useRef(null);
useGSAP(() => {
// animate only this component
}, { scope: ref });
return <article ref={ref}>...</article>;
}A global interaction engine is more difficult because it can touch elements rendered by many different route segments.
That creates a race:
server HTML arrives
↓
client begins hydrating route
↓
global engine scans document
↓
GSAP writes inline styles
↓
React reaches the same element
↓
hydration mismatchThe fix is not to abandon global behaviors. It is to give them a hydration boundary.
Create an explicit “route is ready” signal
I like treating hydration readiness as application state.
The route shell can emit a custom event after the client has had a chance to hydrate the new tree:
useEffect(() => {
let a = 0;
let b = 0;
a = requestAnimationFrame(() => {
b = requestAnimationFrame(() => {
document.documentElement.dataset.routeHydrated = pathname;
window.dispatchEvent(
new CustomEvent("portfolio:route-hydrated", {
detail: { pathname },
}),
);
});
});
return () => {
cancelAnimationFrame(a);
cancelAnimationFrame(b);
};
}, [pathname]);Why two frames?
Not because two frames are a universal React API. They are simply a pragmatic boundary that gives the browser and React a stable paint opportunity before global DOM mutation begins.
The important design idea is the event, not the exact delay.
Now the interaction engine can wait.
window.addEventListener("portfolio:route-hydrated", initializeInteractions);Global motion becomes a consumer of route readiness instead of a competitor with hydration.
Initial state belongs in CSS when possible
Another reliable strategy is to put the pre-animation state in CSS instead of applying it with JavaScript during hydration.
For example:
.js [data-reveal] {
opacity: 0;
transform: translateY(16px);
}
.js.route-ready [data-reveal] {
/* JS takes ownership after hydration */
}The server and client initially agree because the same stylesheet describes the state.
Then GSAP animates from a known visual baseline.
This also avoids a flash where content appears for one frame and then jumps back to the start of its reveal animation.
I do not use this approach blindly for essential content, because hiding large amounts of text before JavaScript is ready can hurt resilience. But for controlled visual elements it is useful.
Do not pass undefined into special GSAP plugin properties
Hydration was not the only lesson I learned from this class of bug.
Some GSAP properties are handled by plugin-specific code rather than as ordinary object values.
A conditional object such as:
gsap.to(card, {
xPercent: depth * 4,
clearProps: instant ? undefined : "willChange",
});looks harmless from a TypeScript perspective.
But a plugin may expect a string and process it immediately. If the internal implementation calls .split() on that value, undefined becomes a runtime crash.
The safer pattern is to change the shape of the operation instead of the value.
if (instant) {
gsap.set(card, {
xPercent: depth * 4,
});
} else {
gsap.to(card, {
xPercent: depth * 4,
onComplete: () => {
card.style.willChange = "auto";
},
});
}This is a useful general rule for animation configuration:
Optional behavior is often safer as an optional property or code branch than as a special property receiving
undefined.
Route transitions make stale DOM references more likely
A card can exist when a timeline is scheduled and be gone by the time a delayed callback runs.
That is common in animated routing:
click project
↓
start shared-element transition
↓
route begins replacing content
↓
delayed timeline callback fires
↓
old card is disconnectedBefore mutating a stored element, I prefer a cheap guard:
if (!card?.isConnected) return;That does not replace proper cleanup, but it makes asynchronous animation code more defensive.
The stronger architecture is still to kill timelines and delayed calls when their owning component unmounts.
Persistent shells need route-aware cleanup
In App Router projects, navigation, cursors, smooth-scroll providers and transition overlays can remain mounted while the page changes.
That means they should not assume “mounted once” equals “initialized once.”
A global engine needs a cycle like:
route A hydrated
→ scan route A
→ attach route A interactions
route change
→ destroy route A interactions
route B hydrated
→ scan route B
→ attach route B interactionsIf it only scans on application mount, new content may never receive behaviors.
If it scans on every render without cleanup, behaviors can duplicate.
The route-ready event gives both setup and teardown a clear place in the architecture.
Keep article content boring on purpose
One outcome of solving hydration problems is that I became more selective about where motion belongs.
A portfolio hero can justify global tilt, cursor labels and scroll choreography.
A long-form article usually cannot.
For reading surfaces I prefer:
- stable server-rendered typography,
- subtle section reveals at most,
- no pointer-driven transforms on paragraphs,
- no animation required to access code or links,
- progress indicators that do not change layout.
This reduces both hydration complexity and cognitive load.
The best architecture is often the one that gives different pages different motion budgets.
Hydration is part of interaction design
It is tempting to think of hydration as framework plumbing that has nothing to do with visual design.
But in a highly interactive application, it defines when the browser is allowed to become alive.
If animation starts too early, React loses trust in the DOM.
If animation starts too late, the interface feels unresponsive.
The solution is a deliberate handoff:
server renders structure
→ React hydrates behavior
→ route announces readiness
→ motion system enhances the stable DOMOnce that sequence is explicit, a whole class of mysterious warnings becomes much easier to prevent.