Skip to content

[FEATURE] Declarative animation suppression during measurement initialization #3560

@gurkerl83

Description

@gurkerl83

Current Workaround (CSS-only)

We are currently forced to use a plain HTML <div> with CSS transitions instead of Framer Motion:

<div 
  style={{
    opacity: isSettled && isVisible ? 1 : 0,
    transition: isSettled ? 'opacity 0.3s ease-in-out' : 'none'
  }} 
/>

How this works:

  1. transition: 'none' disables interpolation:
    Setting transition to 'none' completely disables the browser's interpolation engine. When opacity changes during this time, the browser applies the new value instantly—no tweening, no easing.

  2. isSettled && isVisible forces hidden state during init:
    During initialization (isSettled === false), we force opacity to 0 regardless of what the measurement system reports, preventing any visual appearance during the unstable period.

  3. The handoff:
    When isSettled flips to true, transition enables and the element snaps instantly to the actual isVisible state. Only subsequent changes animate smoothly.

Concrete Example: The IntersectionObserver Flutter

A sticky date header fades based on scroll position calculated via an IntersectionObserver. Here is the timeline of what happens during initialization:

The measurement chaos (first 100ms):

  • T=0ms: Observer not initialized → isVisible: false, isSettled: false
  • T=20ms: Observer fires first measurement → isVisible: true (element thinks it's visible)
  • T=40ms: Measurement refines/corrects → isVisible: false (actually it's hidden)
  • T=60ms: Layout shifts slightly → isVisible: true (correction again)

With CSS workaround (working):

T=0ms:  isSettled=false, isVisible=false  
        opacity: 0 (forced), transition: none → Renders invisible instantly

T=20ms: isSettled=false, isVisible=true   
        opacity: 0 (forced by && logic), transition: none → Still invisible, no animation triggered

T=40ms: isSettled=false, isVisible=false  
        opacity: 0, transition: none → No change

T=100ms: isSettled=true (settling complete), isVisible=false
         opacity: 0, transition: opacity 0.3s → Snaps to correct state instantly (no fade because no state change)
         
[Later when user scrolls]: isVisible becomes true
                          opacity animates 0→1 over 0.3s → Smooth fade in

Why Existing Framer Motion APIs Are Insufficient

  • initial={false}: Only suppresses the mount animation (T=0ms). At T=20ms, T=40ms, and T=60ms, every state change triggers a new animation.
  • Dynamic transition: { duration: 0 }: When isSettled flips to true, if isVisible is true, Framer Motion animates from 0 (forced value) to 1 over 0.3s, triggering the exact fade-in we wanted to suppress.
  • transition.delay: Only delays the animation start time. If isVisible flips 5 times during a 150ms delay, the final animation still executes from whatever intermediate value the element was left at.
  • AnimatePresence: Designed for mount/unmount transitions. Using it for prop-level flutter causes DOM thrashing, which triggers more IntersectionObserver updates and worsens the flicker.

The Core Architectural Limitation

CSS allows removing the transition mechanism entirely (transition: none) during unstable periods. Framer Motion's animation engine is always active once a component mounts. There is no declarative API to say: "Disable all interpolation for 150ms, snap instantly to values, then enable smooth transitions only for future changes."

Proposed API

Option A: settled boolean prop

<motion.div
  animate={{ opacity: isVisible ? 1 : 0 }}
  transition={{ duration: 0.3 }}
  settled={isSettled} // When false: CSS-equivalent of 'transition: none'
/>

When settled flips from falsetrue, the current animate value applies instantly (no catch-up animation), and only subsequent changes respect the transition definition.

Option B: settleDuration number prop

<motion.div
  animate={{ opacity: isVisible ? 1 : 0 }}
  transition={{ duration: 0.3 }}
  settleDuration={150} // First 150ms: CSS-equivalent of 'transition: none'
/>

Use Cases

  • Scroll-driven header visibility (IntersectionObserver initialization)
  • Dynamic layout calculations (reading element bounds before animating)
  • Virtual list render passes (position stabilizes after initial measurement)
  • Any async measurement that may refine its initial guess shortly after mount

Metadata

Metadata

Assignees

No one assigned

    Labels

    featureNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions