-
-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Description
1. Read the FAQs
Done.
2. Describe the bug
When a parent motion.div uses variant propagation (initial="initial" / animate="animate" with variants) and when: "beforeChildren", descendant motion.* components that rely on propagated variants (i.e. they define variants but no explicit initial/animate) will skip their enter animation if they mount asynchronously — for example, when loaded via React.lazy() inside a <Suspense> boundary.
The parent's initial → animate transition starts (and often completes) before the lazy-loaded descendants mount. By the time they appear in the tree, the parent's MotionContext already carries the animate state, so the children resolve directly to their animate variant values without any transition.
On subsequent renders where the lazy module is cached (e.g. navigating away and back), the children mount synchronously and the animation works correctly. This makes the bug appear intermittent — it only manifests on first load when the module hasn't been fetched yet.
This is related to but distinct from #2269 (animations freezing when a child suspends mid-animation). This issue is specifically about variant propagation to descendants that mount in a later React commit than their animating parent.
3. CodeSandbox reproduction
Minimal reproduction code:
import { motion, AnimatePresence } from "motion/react";
import { lazy, Suspense, useState, useLayoutEffect } from "react";
// Simulate a lazy-loaded component with descendants that use variant propagation
const LazyChild = lazy(
() =>
new Promise<{ default: React.ComponentType }>((resolve) => {
// Small delay to simulate module fetch — even 1ms is enough
// because it forces React to commit in a separate microtask
setTimeout(() => {
resolve({
default: () => {
const childVariants = {
initial: { opacity: 0, y: 20 },
animate: {
opacity: 1,
y: 0,
transition: { duration: 0.5 },
},
};
return (
<motion.div variants={childVariants}>
<p>
I should fade/slide in, but on first load I appear instantly.
</p>
</motion.div>
);
},
});
}, 50);
})
);
const parentVariants = {
initial: { opacity: 0 },
animate: {
opacity: 1,
transition: { when: "beforeChildren", duration: 0.3 },
},
exit: {
opacity: 0,
transition: { when: "afterChildren", duration: 0.3 },
},
};
export default function App() {
const [show, setShow] = useState(false);
// Defer to trigger "enter" rather than "appear" in AnimatePresence
useLayoutEffect(() => {
setShow(true);
}, []);
return (
<div>
<AnimatePresence mode="wait">
{show && (
<motion.div
key="stage"
initial="initial"
animate="animate"
exit="exit"
variants={parentVariants}
>
<Suspense fallback={<div>Loading...</div>}>
<LazyChild />
</Suspense>
</motion.div>
)}
</AnimatePresence>
</div>
);
}4. Steps to reproduce
- Use code above
- Add a button that toggles
showoff and on. After toggling, the lazy module is cached, and the child correctly animates in on re-entry
5. Expected behavior
Descendant motion.* components that define variants matching the parent's variant names should animate from initial to animate even if they mount asynchronously after the parent. The when: "beforeChildren" orchestration should wait for all children — including those resolved by Suspense — before beginning the parent-to-children animation sequence.
Alternatively, if late-mounting children cannot participate in the parent's original orchestration, they should at least detect that they've "missed" the initial state and play their initial → animate transition independently upon mount.
6. Video or screenshots
N/A — the effect is that on first page load, the child component appears instantly at full opacity instead of fading/sliding in.
7. Environment details
motion12.34.1- React 19
- Next.js 15 (App Router with code-splitting via
React.lazy) - Chrome / macOS
What is the intended pattern for variant propagation when descendants mount asynchronously (e.g. behind a <Suspense> boundary with React.lazy)?
Our current workaround is to defer the parent's animation by holding animate="initial" until a signal component inside the <Suspense> boundary fires via useLayoutEffect, then switching to animate="animate". This ensures all children are mounted before the transition begins. But this feels like it should be handled by the library, especially given that when: "beforeChildren" already expresses the intent to sequence parent and child animations.
Would it be feasible for Motion to:
- Detect that a Suspense boundary exists between the parent and propagated children, and defer orchestration until the boundary resolves?
- Or, allow late-mounting children to "catch up" by playing their variant transition from
initialwhen they first mount into an already-animatecontext?