plans/issues/issue-2362.md
layoutId element joins an existing shared stackExecutor instructions: This plan has a hard DECISION GATE (Step 0). Do not write fix code until the maintainer has picked an option in
plans/issues/README.md. The repro is fully specified inline in the issue (no sandbox needed). Update the README row when done.Drift check (run first):
gh api repos/motiondivision/motion/issues/2362 --jq .state→ must beopen.git diff --stat 42bfbe3ed..HEAD -- packages/motion-dom/src/render/utils/animation-state.ts packages/framer-motion/src/motion/utils/use-visual-element.ts packages/motion-dom/src/projection/shared/stack.ts
42bfbe3ed, 2026-06-11Conditionally-rendered elements sharing a layoutId (the tab-underline
pattern: {active && <motion.div layoutId="underline" initial={{opacity:0}} animate={{opacity:1}} />}) run their initial → animate enter animation on
EVERY tab switch, layered on top of the built-in shared-element crossfade —
the underline visibly fades each time it "moves". The whole point of
layoutId is continuity; users reasonably expect enter animations to apply
only when the element is genuinely new, not when it is taking over from a
predecessor. There is currently no supported way to express that.
animateChanges() runs from useEffect/
useIsomorphicLayoutEffect in
packages/framer-motion/src/motion/utils/use-visual-element.ts:135-186.
Initial-state suppression exists only via presence context:
blockInitialAnimation: presenceContext ? presenceContext.initial === false : false
(use-visual-element.ts:70-72), consumed in
packages/motion-dom/src/render/utils/animation-state.ts:345
(isInitialRender && visualElement.blockInitialAnimation).registerSharedNode → NodeStack.promote(node)
(packages/motion-dom/src/projection/shared/stack.ts:45-73) sets
node.resumeFrom = prevLead and adopts the predecessor's snapshot. The
built-in opacity crossfade (mixValues,
packages/motion-dom/src/projection/animation/mix-values.ts:34-46) fades
the lead in from 0 with easeCrossfadeIn — so a user-supplied
opacity: 0 → 1 is double-fading.use-visual-element.ts:97-109) and its root's sharedNodes map is
consultable before effects run — so "am I joining an existing stack with a
live lead?" is knowable in time to block the initial animation.initialPromotionConfig /
SwitchLayoutGroupContext (packages/framer-motion/src/context/SwitchLayoutGroupContext.ts)
exists for exactly this class of control but is only wired for the removed
AnimateSharedLayout-style usage (consumed in
use-visual-element.ts:95,107 and MeasureLayout.tsx:47-49).Pick one in the README row:
layoutEnterAnimation={false} or extending initial semantics is NOT
possible — so a dedicated prop: when the element mounts INTO an existing
stack whose prevLead has a live instance, treat as
blockInitialAnimation = true. Opt-in, zero behavior change for existing
apps.initial={false} via
app state); close as working-as-intended. Gate: APPROVED-CLOSE.The plan below implements A (B differs only by removing the prop check).
Standard CLAUDE.md Cypress recipe (React 18 + 19), yarn build,
npx jest --config packages/framer-motion/jest.config.json --testPathPattern="<filter>".
In scope (Option A):
packages/framer-motion/src/motion/utils/use-visual-element.tspackages/motion-dom/src/render/types.ts /
packages/framer-motion/src/motion/types.ts (prop type)dev/react/src/tests/layout-id-enter-animation.tsx (create)packages/framer-motion/cypress/integration/layout-id-enter-animation.ts (create)Out of scope:
mix-values.ts.NodeStack/create-projection-node.ts internals.Test page from the issue's inline snippet: three tabs; per-tab
{active === i && <motion.div layoutId="underline" initial={{opacity: 0}} animate={{opacity: 1}} transition={{duration: 1.5}} layoutEnterAnimation={false} />}.
Spec: mount → click tab 2 → sample the underline's computed opacity ~300ms
in with .then(). Expected with fix: opacity ≈ crossfade-only value (close
to 1 well before 1.5s, since the user duration no longer applies); on
current main the user fade makes it ≈ 0.2. Also assert first-ever mount DOES
fade (enter animation preserved when no predecessor exists).
Verify: fails on main (the prop is ignored / opacity follows the 1.5s user fade).
In useVisualElement, where blockInitialAnimation is computed
(use-visual-element.ts:70-72), it is too early — the projection node doesn't
exist yet. Instead, after createProjectionNode(...) (line 103-109), if the
new prop is set and props.layoutId, look up
visualElement.projection.root?.sharedNodes?.get(layoutId) — root may only
resolve post-mount for the first node in a tree; handle by checking in the
useIsomorphicLayoutEffect (line 135) BEFORE animationState.animateChanges()
runs in the later useEffect (line 165): if the node's stack has a
prevLead with a live instance, set
visualElement.blockInitialAnimation = true for this mount. Confirm
ordering: animateChanges() for non-handoff runs in useEffect
(use-visual-element.ts:165-170), which is after layout effects — so the flag
set in the layout effect lands in time.
Verify: Step 1 spec green on React 18 + 19; existing
animate-presence-layout.ts, layout-shared.ts specs green.
Add the prop to the layout-prop types (where layoutCrossfade is declared —
grep layoutCrossfade in packages/framer-motion/src/motion/types.ts) with
a doc comment. Note in PR body that the docs site needs a paragraph.
blockInitialAnimation set before
animateChanges suppresses initial keyframes — model on existing
animation-state tests under
packages/framer-motion/src/render/utils/__tests__/.plans/issues/README.md row updatedblockInitialAnimation timing doesn't hold (flag set too late and the
enter animation still fires): do NOT start moving animateChanges
call sites; report — that ordering is load-bearing for optimized appear
handoff (use-visual-element.ts:150-163).layoutExitAnimation).