React - animate mount and unmount of a single component

112,529

Solution 1

This is a bit lengthy but I've used all the native events and methods to achieve this animation. No ReactCSSTransitionGroup, ReactTransitionGroup and etc.

Things I've used

  • React lifecycle methods
  • onTransitionEnd event

How this works

  • Mount the element based on the mount prop passed(mounted) and with default style(opacity: 0)
  • After mount or update, use componentDidMount (componentWillReceiveProps for further updates)to change the style (opacity: 1) with a timeout(to make it async).
  • During unmount, pass a prop to the component to identify unmount, change the style again(opacity: 0), onTransitionEnd, remove unmount the element from the DOM.

Continue the cycle.

Go through the code, you'll understand. If any clarification is needed, please leave a comment.

Hope this helps.

class App extends React.Component{
  constructor(props) {
    super(props)
    this.transitionEnd = this.transitionEnd.bind(this)
    this.mountStyle = this.mountStyle.bind(this)
    this.unMountStyle = this.unMountStyle.bind(this)
    this.state ={ //base css
      show: true,
      style :{
        fontSize: 60,
        opacity: 0,
        transition: 'all 2s ease',
      }
    }
  }
  
  componentWillReceiveProps(newProps) { // check for the mounted props
    if(!newProps.mounted)
      return this.unMountStyle() // call outro animation when mounted prop is false
    this.setState({ // remount the node when the mounted prop is true
      show: true
    })
    setTimeout(this.mountStyle, 10) // call the into animation
  }
  
  unMountStyle() { // css for unmount animation
    this.setState({
      style: {
        fontSize: 60,
        opacity: 0,
        transition: 'all 1s ease',
      }
    })
  }
  
  mountStyle() { // css for mount animation
    this.setState({
      style: {
        fontSize: 60,
        opacity: 1,
        transition: 'all 1s ease',
      }
    })
  }
  
  componentDidMount(){
    setTimeout(this.mountStyle, 10) // call the into animation
  }
  
  transitionEnd(){
    if(!this.props.mounted){ // remove the node on transition end when the mounted prop is false
      this.setState({
        show: false
      })
    }
  }
  
  render() {
    return this.state.show && <h1 style={this.state.style} onTransitionEnd={this.transitionEnd}>Hello</h1> 
  }
}

class Parent extends React.Component{
  constructor(props){
    super(props)
    this.buttonClick = this.buttonClick.bind(this)
    this.state = {
      showChild: true,
    }
  }
  buttonClick(){
    this.setState({
      showChild: !this.state.showChild
    })
  }
  render(){
    return <div>
        <App onTransitionEnd={this.transitionEnd} mounted={this.state.showChild}/>
        <button onClick={this.buttonClick}>{this.state.showChild ? 'Unmount': 'Mount'}</button>
      </div>
  }
}

ReactDOM.render(<Parent />, document.getElementById('app'))
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.3.2/react-with-addons.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.1.0/react-dom.min.js"></script>
<div id="app"></div>

Solution 2

Here is my solution using the new hooks API (with TypeScript), based on this post, for delaying the component's unmount phase:

function useDelayUnmount(isMounted: boolean, delayTime: number) {
    const [ shouldRender, setShouldRender ] = useState(false);

    useEffect(() => {
        let timeoutId: number;
        if (isMounted && !shouldRender) {
            setShouldRender(true);
        }
        else if(!isMounted && shouldRender) {
            timeoutId = setTimeout(
                () => setShouldRender(false), 
                delayTime
            );
        }
        return () => clearTimeout(timeoutId);
    }, [isMounted, delayTime, shouldRender]);
    return shouldRender;
}

Usage:

const Parent: React.FC = () => {
    const [ isMounted, setIsMounted ] = useState(true);
    const shouldRenderChild = useDelayUnmount(isMounted, 500);
    const mountedStyle = {opacity: 1, transition: "opacity 500ms ease-in"};
    const unmountedStyle = {opacity: 0, transition: "opacity 500ms ease-in"};

    const handleToggleClicked = () => {
        setIsMounted(!isMounted);
    }

    return (
        <>
            {shouldRenderChild && 
                <Child style={isMounted ? mountedStyle : unmountedStyle} />}
            <button onClick={handleToggleClicked}>Click me!</button>
        </>
    );
}

CodeSandbox link.

Solution 3

Using the knowledge gained from Pranesh's answer, I came up with an alternate solution that's configurable and reusable:

const AnimatedMount = ({ unmountedStyle, mountedStyle }) => {
  return (Wrapped) => class extends Component {
    constructor(props) {
      super(props);
      this.state = {
        style: unmountedStyle,
      };
    }

    componentWillEnter(callback) {
      this.onTransitionEnd = callback;
      setTimeout(() => {
        this.setState({
          style: mountedStyle,
        });
      }, 20);
    }

    componentWillLeave(callback) {
      this.onTransitionEnd = callback;
      this.setState({
        style: unmountedStyle,
      });
    }

    render() {
      return <div
        style={this.state.style}
        onTransitionEnd={this.onTransitionEnd}
      >
        <Wrapped { ...this.props } />
      </div>
    }
  }
};

Usage:

import React, { PureComponent } from 'react';

class Thing extends PureComponent {
  render() {
    return <div>
      Test!
    </div>
  }
}

export default AnimatedMount({
  unmountedStyle: {
    opacity: 0,
    transform: 'translate3d(-100px, 0, 0)',
    transition: 'opacity 250ms ease-out, transform 250ms ease-out',
  },
  mountedStyle: {
    opacity: 1,
    transform: 'translate3d(0, 0, 0)',
    transition: 'opacity 1.5s ease-out, transform 1.5s ease-out',
  },
})(Thing);

And finally, in another component's render method:

return <div>
  <ReactTransitionGroup>
    <Thing />
  </ReactTransitionGroup>
</div>

Solution 4

I countered this problem during my work, and simple as it seemed, it is really not in React. In a normal scenario where you render something like:

this.state.show ? {childen} : null;

as this.state.show changes the children are mounted/unmounted right away.

One approach I took is creating a wrapper component Animate and use it like

<Animate show={this.state.show}>
  {childen}
</Animate>

now as this.state.show changes, we can perceive prop changes with getDerivedStateFromProps(componentWillReceiveProps) and create intermediate render stages to perform animations.

A stage cycle might look like this

We start with Static Stage when the children is mounted or unmounted.

Once we detect the show flag changes, we enter Prep Stage where we calculate necessary properties like height and width from ReactDOM.findDOMNode.getBoundingClientRect().

Then entering Animate State we can use css transition to change height, width and opacity from 0 to the calculated values (or to 0 if unmounting).

At the end of transition, we use onTransitionEnd api to change back to Static stage.

There are much more details to how the stages transfer smoothly but this could be overall idea:)

If anyone interested, I created a React library https://github.com/MingruiZhang/react-animate-mount to share my solution. Any feedback welcome:)

Solution 5

Framer motion

Install framer-motion from npm.

import { motion, AnimatePresence } from "framer-motion"

export const MyComponent = ({ isVisible }) => (
  <AnimatePresence>
    {isVisible && (
      <motion.div
        initial={{ opacity: 0 }}
        animate={{ opacity: 1 }}
        exit={{ opacity: 0 }}
      />
    )}
  </AnimatePresence>
)
Share:
112,529
ffxsam
Author by

ffxsam

Updated on February 09, 2022

Comments

  • ffxsam
    ffxsam over 2 years

    Something this simple should be easily accomplished, yet I'm pulling my hair out over how complicated it is.

    All I want to do is animate the mounting & unmounting of a React component, that's it. Here's what I've tried so far and why each solution won't work:

    1. ReactCSSTransitionGroup - I'm not using CSS classes at all, it's all JS styles, so this won't work.
    2. ReactTransitionGroup - This lower level API is great, but it requires you to use a callback when the animation is complete, so just using CSS transitions won't work here. There are always animation libraries, which leads to the next point:
    3. GreenSock - The licensing is too restrictive for business use IMO.
    4. React Motion - This seems great, but TransitionMotion is extremely confusing and overly complicated for what I need.
    5. Of course I can just do trickery like Material UI does, where the elements are rendered but remain hidden (left: -10000px) but I'd rather not go that route. I consider it hacky, and I want my components to unmount so they clean up and are not cluttering up the DOM.

    I want something that's easy to implement. On mount, animate a set of styles; on unmount, animate the same (or another) set of styles. Done. It also has to be high performance on multiple platforms.

    I've hit a brick wall here. If I'm missing something and there's an easy way to do this, let me know.

    • Pranesh Ravi
      Pranesh Ravi over 7 years
      What kind of animation are we talking here?
    • ffxsam
      ffxsam over 7 years
      Just something simple, like a CSS opacity fade in and a transform: scale
    • Pranesh Ravi
      Pranesh Ravi over 7 years
      Point 1 and 2 confuses me. What kind of animations are you using? JS transitions or CSS transitions ?
    • ffxsam
      ffxsam over 7 years
      Don't confuse CSS styles/classes (e.g. .thing { color: #fff; }) with JS styles (const styles = { thing: { color: '#fff' } }))
    • Pranesh Ravi
      Pranesh Ravi over 7 years
      But the problem is, when you try to change the style using javascript, you're actually replacing the style of a element which won't give any transition.
    • Tahir Ahmed
      Tahir Ahmed over 7 years
      I am pretty sure you can use a combination of ReactTransitionGroup and GSAP. I have posted similar answers before which are here and here. Please go through them and let me know if anything is unclear.
    • ffxsam
      ffxsam over 7 years
      See above, I mention GSAP/GreenSock, which I don't want to use.
    • ffxsam
      ffxsam over 7 years
      @PraneshRavi (regarding replacing the style and won't transition) - That's not actually true. See this example: jsfiddle.net/mqkhh3qq
    • Tahir Ahmed
      Tahir Ahmed over 7 years
      @ffxsam hmm, now I am curious what made you conclude the result that you concluded for GSAP.
    • ffxsam
      ffxsam over 7 years
      @TahirAhmed GSAP costs $150/year for commercial use where you charge access to a web-based service. It's just something I'm not interested in at the moment.
    • Tahir Ahmed
      Tahir Ahmed over 7 years
      @ffxsam I am guessing you have already been here, here and here and you still came to same conclusion.
    • ffxsam
      ffxsam over 7 years
      @TahirAhmed Yes, of course
    • ffxsam
      ffxsam over 7 years
      @TahirAhmed " If end users are charged a usage/access/license fee, please sign up for a "Business Green" Club GreenSock membership"
    • Tahir Ahmed
      Tahir Ahmed over 7 years
      @ffxsam fair enough. thanks.
    • Tahir Ahmed
      Tahir Ahmed over 7 years
      @ffxsam I still have one more link to share with you :)
    • ffxsam
      ffxsam over 7 years
      @TahirAhmed Oh nice! Lemme check this out in detail when I have some time
    • Webwoman
      Webwoman almost 5 years
      Concerning ReactTransitionGroup you said: This lower level API is great, but it requires you to use a callback when the animation is complete, could you extrapolate what you thought about please?
    • Webwoman
      Webwoman almost 5 years
      For thoses who want, ReactCSSTransitionGroup can work combined to styled-component
  • ffxsam
    ffxsam over 7 years
    Thanks for this! Where did you learn about onTransitionEnd? I don't see it in the React docs.
  • Pranesh Ravi
    Pranesh Ravi over 7 years
    @ffxsam facebook.github.io/react/docs/events.html It's under the transition events.
  • ffxsam
    ffxsam over 7 years
    How did you know what it did though, the documentation doesn't explain anything. Another question: how did you know componentWillReceiveProps can return something? Where can I read more on that?
  • Pranesh Ravi
    Pranesh Ravi over 7 years
    @ffxsam onTransitionEnd is a native JavaScript event. You can google about it. facebook.github.io/react/docs/… will give you an idea about componentWillReceiveProps.
  • ffxsam
    ffxsam over 7 years
    I saw the documentation on componentWillReceiveProps, it doesn't mention returning anything. Weird.
  • Pranesh Ravi
    Pranesh Ravi over 7 years
    @ffxsam you won't return anything but you can update the state based on the newProp. The reason I use return is to prevent the code below return from executing. It's a better way of writing if else. With a reaturn in the if, you don't need a else statement.
  • ffxsam
    ffxsam over 7 years
    Oh I got it - you're just executing the method and returning in one shot.
  • ffxsam
    ffxsam over 7 years
    Thanks for your awesome answer, Pranesh! :) I learned a lot from this.
  • Pranesh Ravi
    Pranesh Ravi over 7 years
    @ffxsam Yes, to avoid else statement. ComponentWillReceiveProps doesn't take any return value. So, it won't cause any problem.
  • ffxsam
    ffxsam over 7 years
    BTW I think there's a mistake in your code. In your Parent component, you reference this.transitionEnd
  • Ser
    Ser about 6 years
    The example above doesn't check if the transitionEnd event comes from that element or a child one. You should add a className="my-animation" and check inside transitionEnd(e){ that e.target.className === "my-animation" otherwise it can be mess up
  • Mingrui  Zhang
    Mingrui Zhang almost 6 years
    Thank you for your feedback, sorry for crude answer earlier. I added more detail and a diagram to my answer, hope this can be more helpful to others.
  • Bugs
    Bugs almost 6 years
    @MingruiZhang It's good to see that you've taken the comments positively and improved your answer. It's very refreshing to see. Good work.
  • Micros
    Micros over 5 years
    Project is no longer maintained (2018)
  • cherouvim
    cherouvim over 5 years
    This doesn't unmount App though, but App simply knows when to not render anything.
  • Admin
    Admin over 5 years
    And how do you mount/unmount @ffxsam?
  • Rokit
    Rokit over 5 years
    How is componentWillLeave() and componentWillEnter() getting called in AnimatedMount?
  • Webwoman
    Webwoman about 5 years
    Doesn't works for me, here my sandbox: codesandbox.io/s/p9m5625v6m
  • Webwoman
    Webwoman about 5 years
    elegant solution, would be great if you have added some comments :)
  • Webwoman
    Webwoman about 5 years
    also why use typescrypt's extension since it works well in javascript's extension?
  • Webwoman
    Webwoman about 5 years
    also your console returns "cannot find namespace NodeJS timeout"
  • deckele
    deckele about 5 years
    @Webwoman Thanks for your comments. I can't recreate your reported problem with "NodeJS timeout", see my CodeSandbox link below the answer. Regarding TypeScript, I personally prefer using it over JavaScript, although both are viable of course.
  • Aleks
    Aleks over 4 years
    If you use styled components, you can simply pass showprop to H1 and do all logic inside styled component. Like...animation: ${({ show }) => show ? entranceKeyframes : exitKeyframes} 300ms ease-out forwards;
  • Scott Martin
    Scott Martin almost 4 years
    componentWillReceiveProps is now deprecated as unsafe. It would be good for someone to update this answer to use componentDidUpdate.
  • Slbox
    Slbox about 3 years
    This can't possibly work since the methods are uncalled, and as expected, it doesn't work.
  • Marko Knöbl
    Marko Knöbl over 2 years
    This solution doesn't work as intended for me. If I set the transition / timeout times to 2s / 2000ms I can clearly see that when the enter animation is triggered, the element stays hidden for 2s, and only then transitions in for 2s.
  • Marko Knöbl
    Marko Knöbl over 2 years
    I think this answer is outdated ... It seems like this example requires ReactTransitionGroup in the background, which used to be part of React and now has a separate package. But that package also provides Transition and CSSTransition which would be more appropriate here.
  • Viraj Singh
    Viraj Singh over 2 years
    @Webwoman for some reason the typescript is treating the hook file as node.js file so it's forcing to let timeoutId: NodeJS.Timeout; instead of number type. try let timeoutId: ReturnType<typeof setTimeout>; or try window.setTimeout which has number type
  • Nate Levin
    Nate Levin almost 2 years
    To fix the TypeScript error Type 'Timeout' is not assignable to type 'number'., add window. in front of the setTimeout.