reactjsframer-motionreact-portal

Framer Motion & React Portals : No Transitions Occur


I'm adding some React modules to an existing website. To do so, I'm mounting my React components via React Portals.

In short, while my components do mount as expected, the animations are not triggered. Eg, they remain stuck with an opacity of 0. I would expect these components to transition from opacity 0 to 1 on mount, then from 0 to 1 on exit as per the initial animate and exit props on each child within <AnimatePresence>.

A contrived but working example showing the bug can be found on CodeSandbox here.

Code from the CodeSandbox also shown below:

index.html

<!DOCTYPE html>
<html lang="en">
  <head>
   ...
  </head>

  <body>
    <div id="react-root"></div>
    <h1>Static Content</h1>
    <button id="start-portal">Show Modal</button>
    <div id="portal"></div>
  </body>
</html>

Context.js

import React from "react";

const INITIAL_STATE = {
  activeItem: -1
};

const Context = React.createContext(null);

const ContextProvider = ({ children }) => {
  const [store, setStore] = React.useState(INITIAL_STATE);

  React.useEffect(() => {
    const startEl = document.getElementById("start-portal");
    startEl.addEventListener("click", () => setActiveItem(0));
    return () => {
      startEl.removeEventListener("click", () => setActiveItem(0));
    };
  }, []);

  const setActiveItem = (activeItem) => {
    console.log("SETTING ACTIVE ITEM TO " + activeItem);
    setStore((prevState) => {
      return {
        ...prevState,
        activeItem
      };
    });
  };

  return (
    <Context.Provider
      value={{
        ...store,
        setActiveItem
      }}
    >
      {children}
    </Context.Provider>
  );
};

function useContext() {
  return React.useContext(Context);
}

export { ContextProvider, useContext };

index.js

import React from "react";
import ReactDOM from "react-dom";

import Modal from "./Modal";

import { ContextProvider } from "./Context";

import "./styles.css";

const REACT_ROOT = document.getElementById("react-root");
const PORTAL_ROOT = document.getElementById("portal");

const PortalModal = () => {
  if (!PORTAL_ROOT) return null;
  return ReactDOM.createPortal(<Modal />, PORTAL_ROOT);
};

const App = () => (
  <ContextProvider>
    <PortalModal />
  </ContextProvider>
);

ReactDOM.render(<App />, REACT_ROOT);

Modal.js

import React from "react";
import { AnimatePresence, m } from "framer-motion";

import { useContext } from "./Context";

const Modal = () => {
  const { activeItem, setActiveItem } = useContext();

  return (
    <AnimatePresence exitBeforeEnter>
      {activeItem === 0 && (
        <m.div
          key={"step-1"}
          className={"step"}
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          exit={{ opacity: 0 }}
        >
          <h2>STEP 1</h2>
          <button onClick={() => setActiveItem(-1)}>CLOSE</button>
          <button onClick={() => setActiveItem(1)}>NEXT</button>
        </m.div>
      )}
      {activeItem === 1 && (
        <m.div
          key={"step-2"}
          className={"step"}
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          exit={{ opacity: 0 }}
        >
          <h2>STEP 2</h2>
          <button onClick={() => setActiveItem(-1)}>CLOSE</button>
          <button onClick={() => setActiveItem(0)}>PREV</button>
        </m.div>
      )}
    </AnimatePresence>
  );
};

export default Modal;


Solution

  • You can switch from m to motion (in the import and components) and everything works as expected.

    m isn't a 1:1 alias for motion, it's a slimmed-down version. If you want to use animations with the m component you'll need to import an additional "feature package" like domAnimation or domMax.

    More info here: Framer.com: Reduce bundle size