Motion Animation Library

repository·main·Indexed 12 days ago

https://github.com/motiondivision/motion

An open-source animation library providing high-performance, GPU-accelerated animations for JavaScript, React, and Vue. It features a hybrid engine combining JavaScript logic with native browser APIs to achieve 120fps performance. The library provides the `animate` function for DOM elements, objects, and values, as well as motion-enhanced components for React (via `motion/react`) and Vue (via `motion-v`).

Tokens
91.7K
Snippets
251
Records
386
Agent score
99%

What's inside Motion

  1. Improve Drag Gesture Quality (Plan 021)

    main

    This plan addresses three specific quality-of-life issues in the drag engine to improve performance and user experience:

    1. Inertia Hard-Stop: Replacing the current 'overdamp hack' (using extremely high stiffness and damping values to simulate dragElastic: false) with a native bounce: false option in the inertia generator. This is more efficient and avoids numeric extremes in spring evaluation.
    2. Resize Throttling: Preventing layout thrashing caused by unthrottled synchronous scalePositionWithinConstraints calls during window resize events. The goal is to ensure resize measurements and re-renders are throttled within a single frame.
    3. Direction-Lock Fairness: Fixing the current Y-axis bias in getCurrentDirection. Currently, the engine checks the Y-axis threshold before the X-axis, which can cause fast diagonal movements to lock to the Y-axis incorrectly. The fix involves comparing magnitudes to lock to the dominant axis.

    Note: These improvements are part of the motion-dom package and affect the drag engine used by both React and vanilla JS implementations.

  2. How keyframe resolution works when restarting animations

    main

    In Motion, stopping an animation effectively destroys it. When you trigger a new animation (e.g., by calling animate() again or changing a prop), Motion creates a brand-new animation instance.

    If the animation target uses implied initial keyframes (like rotate: 360), Motion resolves the first keyframe by reading the current value from the DOM/MotionValue.

    Example Scenario:

    1. You animate rotate: 360 with repeat: Infinity.
    2. The animation stops at 180.
    3. You restart the animation.
    4. Motion reads the current value (180) and resolves the new keyframe sequence as [180, 360].
    5. Because repeat operates on the resolved keyframes, the animation will loop from 180 → 360, then 180 → 360 again, rather than resuming the original 0 → 360 cycle.
  3. How transitionEnd behaves when an animation is interrupted

    main

    In Motion, the transitionEnd property is intended to apply specific styles only after an animation has successfully completed.

    Correct Behavior: If an animation is interrupted (e.g., by starting a new animation on the same value), the transitionEnd values from the interrupted animation should be discarded and not applied.

    Bug Context: In versions prior to v12 (specifically starting from 10.6.0 with WAAPI acceleration), interrupting an animation would cause transitionEnd to be applied immediately mid-animation. For example, animate={{ opacity: 0, transitionEnd: { display: "none" } }} would hide the element instantly if the animation was interrupted, rather than waiting for the opacity transition to finish.

    v12 Fix: The v12 animation engine rewrite ensures that transitionEnd application is gated by the completion of the animation promises. If an animation is stopped or cancelled, its finished promise does not settle in a way that triggers applyTransitionEnd in visual-element-target.ts.

  4. Resolve shared `layoutId` animations relative to a layout ancestor

    main

    By default, shared layoutId animations are resolved in page coordinates. If an unrelated content shift occurs (e.g., a sidebar mounting and pushing a tab strip) at the same time a shared element is transitioning, the element may appear to fly in from the wrong direction because it is animating between its old and new boxes in page-space rather than relative to its container.

    To fix this, you can use the layoutRoot option on a parent element. This forces the subtree to behave as a layout root, allowing shared animations to resolve relative to that ancestor's box. This ensures that simultaneous content shifts in the parent do not distort the shared element's animation path.

    <!-- Conceptual usage pattern -->
    <div layout="true" layoutRoot>
      <!-- Shared elements inside this container will resolve animations relative to this div's box -->
      <div layoutId="tab-underline"></div>
    </div>
  5. How dragConstraints with React refs handle size changes

    main

    When using a React ref for dragConstraints, the draggable area is automatically recalculated if the draggable element or the constraints container changes size. This is achieved via a ResizeObserver that monitors both elements. When a resize is detected, the system resets the internal constraints cache (this.constraints = false) and re-resolves the layout measurements.

    Important Note on Scaling: This mechanism relies on ResizeObserver. Changes made via CSS scale or CSS transforms on an ancestor do not trigger a ResizeObserver event and will not update the constraints. For handling scaled environments, use correctParentTransform and MotionConfig with transformPagePoint.

  6. How multidimensional reordering works in Reorder components

    main

    Motion's Reorder components support multidimensional (grid) reordering by moving away from velocity-based or index-based arithmetic and instead using positional collision detection.

    Instead of guessing direction based on velocity or assuming a uniform grid via modular arithmetic, the system uses the geometric Box (layout) of every registered item. A reorder (swap) is triggered when the dragged item's projected center enters the bounding box of another item. This allows for:

    • Grid layouts: Works with wrapped flex layouts, uneven rows, and mixed item sizes.
    • Multi-position jumps: Items can move multiple slots in a single drag event.
    • Robustness: Removes reliance on velocity sign, making reordering stable even when the pointer is momentarily still or moving obliquely.

    To enable 2D dragging, set the drag prop on the Reorder.Item component.

    // Example concept of enabling 2D drag for reordering
    <Reorder.Group axis="both">
      <Reorder.Item drag />
      {/* ... items ... */}
    </Reorder.Group>
  7. How Motion handles ref prop changes

    main

    In standard React elements (like <div>), React automatically detaches the old ref and attaches the new one when the ref prop identity changes. However, motion components use a stable internal callback ref to manage the underlying visualElement (to prevent breaking animations like AnimatePresence exit transitions).

    Because the internal callback is stable, it does not automatically re-invoke the user's external ref when the user swaps their ref prop. This can result in the new ref.current remaining null even though the component is mounted. To fix this, Motion must manually 're-hydrate' the external ref whenever the ref prop changes, following React's contract: detaching the old ref (setting it to null or calling it with null) and then attaching the new one with the current instance.

  8. Fix press() end-event filtering bugs

    main

    The press() gesture in motion-dom contains a bug where end events (pointerup/pointercancel) are validated after the gesture has already torn down its window listeners and cleared its state. This causes two primary issues:

    1. Multi-touch interference: Lifting a secondary finger (where isPrimary: false) triggers a window pointerup that prematurely clears the press state, preventing the primary finger's end event from ever being processed. This leaves whileTap visually stuck and prevents onTap/onTapCancel from firing.
    2. Swallowed Drag Cancels: If a drag starts during a press, the subsequent pointerup is rejected by the current validity check. This prevents the press from being explicitly 'cancelled', leaving the whileTap state active indefinitely.

    To fix this, the onPointerEnd logic must be restructured to:

    • Ignore non-primary pointer events before any teardown occurs.
    • Ensure the end callback always fires for primary events, passing success: false if isDragActive() is true.
  9. Fix scaleZ not being applied to transform string

    main

    In certain versions of Motion, the scaleZ transform property is silently dropped when using initial, animate, or variants (e.g., motion.div initial={{ scaleZ: 2 }}). While TypeScript types and value maps support scaleZ, it is missing from the transformPropOrder array used by transform builders to serialize properties into the CSS transform string. This causes scaleZ to be treated as a plain style rather than a transform, leading the browser to ignore it.

    To fix this, scaleZ must be added to transformPropOrder in packages/motion-dom/src/render/utils/keys-transform.ts and matrix parsers must be added to packages/motion-dom/src/render/dom/parse-transform.ts to support reading the value back from computed matrices.

  10. Avoid using array indices as keys in Reorder components

    main

    When using Reorder.Item or any component that relies on layout animations (like layout or Reorder.Group), you must use stable, unique identifiers for the key prop (e.g., key={item.id}).

    Using the array index as a key (key={index}) causes React to reuse DOM nodes across different values during removals or reordering. This scrambles projection snapshots and can result in stale transform artifacts, such as a persistent, incorrect translateY even when the component is configured for axis="x" animations.

    // ❌ BAD: Using index as key causes stale transforms during reordering
    {items.map((item, index) => (
      <Reorder.Item key={index} value={item}>
        {item}
      </Reorder.Item>
    ))}
    
    // ✅ GOOD: Using a stable ID ensures correct layout projection
    {items.map((item) => (
      <Reorder.Item key={item.id} value={item}>
        {item.text}
      </Reorder.Item>
    ))}
  11. Fix overdamped spring snapping behavior

    main

    In Motion, overdamped springs (where damping ratio $\zeta > 1$) can exhibit a 'snap' behavior where they visibly jump to the target mid-animation. This is caused by a safety cap on sinh/cosh inputs to prevent Infinity overflows, which breaks the mathematical cancellation required for a smooth decay.

    To resolve this, the spring implementation should use an exponential form instead of hyperbolic functions. This form is algebraically identical but uses only negative exponents ($e^{\lambda t}$ where $\lambda < 0$), which prevents overflow and removes the need for input capping.

    Mathematical Implementation for $\zeta > 1$

    Given:

    • $D$ = initialDelta
    • $V$ = initialVelocity + ω₀D
    • $ω_d = ω_0√(ζ^2 - 1)$

    Coefficients:

    • $\lambda_{slow} = -\u03c9_0 / (\zeta + \sqrt{\zeta^2 - 1})$ (Use this form to avoid catastrophic cancellation at large $\zeta$)
    • $\lambda_{fast} = -\u03c9_0(\zeta + \sqrt{\zeta^2 - 1})$
    • $c_{slow} = (V + \u03c9_d \cdot D) / (2\u03c9_d)$
    • $c_{fast} = D - c_{slow}$

    Equations:

    • Position: $x(t) = target - (c_{slow} \cdot e^{\lambda_{slow} \cdot t} + c_{fast} \cdot e^{\lambda_{fast} \cdot t})$
    • Velocity (px/ms): $v(t) = -(c_{slow} \cdot \lambda_{slow} \cdot e^{\lambda_{slow} \cdot t} + c_{fast} \cdot \lambda_{fast} \cdot e^{\lambda_{fast} \cdot t})$
  12. Behavior of LazyMotion with async feature loading

    main

    When using LazyMotion with asynchronous feature loading (e.g., features={() => new Promise(r => setTimeout(() => r(domAnimation), 100))}), the animation runtime is code-split.

    Important Design Note on initial styles: initial styles are rendered into the style attribute during the initial render (both client-side and server-side). This is intentional to prevent a 'flash' of the final animated state during the first paint.

    If you are using LazyMotion and need content to be visible immediately (First Contentful Paint priority), avoid hiding content via initial={{ opacity: 0 }}. Instead, either:

    1. Load features synchronously.
    2. Avoid using initial to hide elements if the animation delay is undesirable for your UX.