Skip to content

react transition group

Created: 2018-03-30 11:15:53 -0700 Modified: 2018-03-30 14:29:21 -0700

This talks about a specific case that I had in Bot Land where I wanted to animate the width on a mission’s progress bar and then do something only when that was complete.

At first, I thought to use CSS transitions, but that could be cumbersome for several reasons:

  • Differentiating between various transitions that happen
  • Having to add/remove event handlers myself

If you do want to use the CSS way of handling animations, I’ve got some code here that I am going to delete from my codebase since I use React now:

/**
* Adds a transitionend handler in a browser-agnostic way to a DOM element.
* @param {DOMElement} element
*/
export function addTransitionEndToElement(element, handler) {
const transitionEndEventName = getTransitionEndEventName();
if (_.isString(transitionEndEventName)) {
element.addEventListener(transitionEndEventName, handler, false);
} else {
setTimeout(handler, 500);
}
}
export function removeTransitionEndFromElement(element, handler) {
const transitionEndEventName = getTransitionEndEventName();
if (_.isString(transitionEndEventName)) {
element.removeEventListener(transitionEndEventName, handler, false);
}
}
/**
* @return {?string} the name of the transitionend event on this browser
*/
function getTransitionEndEventName () {
var i,
undefined,
el = document.createElement('div'),
transitions = {
'transition':'transitionend',
'OTransition':'otransitionend', // oTransitionEnd in very old Opera
'MozTransition':'transitionend',
'WebkitTransition':'webkitTransitionEnd',
};
for (i in transitions) {
if (transitions.hasOwnProperty(i) && el.style[i] !== undefined) {
return transitions[i];
}
}
return null;
}

Here’s the solution I came up with in React:

import React, { Component } from 'react'; // eslint-disable-line no-unused-vars
import {Row} from 'jsxstyle';
import onlyUpdateForKeys from 'recompose/onlyUpdateForKeys';
import Transition from 'react-transition-group/Transition';
const _ = require('lodash');
const classNames = require('classnames');
const styles = require('../../../stylesheets/main.less');
class ProgressBar extends Component {
constructor(props) {
super(props);
this.state = {
/**
* This is simply needed to re-render the react-transition-group
* component; it NEEDS to change from false to true if the animation
* is going to take place.
* @type {boolean}
*/
transitionInProp: false,
};
}
renderBackground(state) {
// When animationDuration is specified, it means that we don't want to
// rely on a CSS class passed in for "transition" values and that we'll
// add our own here. This boolean has a big enough impact where I should
// ideally split this class into two classes: one that uses react-
// transition-group for animation, and one that doesn't.
const isAnimatingViaReactTransitionGroup = !_.isNil(this.props.animationDuration);
const fillPercent = _.round(_.clamp(this.props.fillPercent, 0, 1), 2) * 100.0;
const endingFillPercent = _.round(_.clamp(_.defaultTo(this.props.endingFillPercent, this.props.fillPercent), 0, 1), 2) * 100.0;
const defaultStyle = {
width: `${fillPercent}%`,
};
if (isAnimatingViaReactTransitionGroup) {
defaultStyle.transition = `width ${this.props.animationDuration}ms ease-in`;
}
const transitionStyles = {
entering: { width: `${endingFillPercent}%` },
entered: { width: `${endingFillPercent}%` },
};
const styleToUse = {
...defaultStyle,
...transitionStyles[state],
};
const finalStyle = isAnimatingViaReactTransitionGroup
? styleToUse
: defaultStyle;
return (
<span style={finalStyle} className={this.props.backgroundStyle}/>
);
}
componentDidMount() {
setTimeout(() => {
this.setState({
transitionInProp: true,
});
}, 500);
}
onFinishAnimatingProgressBar() {
if (_.isFunction(this.props.onFinishAnimatingProgressBar)) {
this.props.onFinishAnimatingProgressBar();
}
}
render() {
const foregroundCss = classNames(
styles.progressBarForeground,
this.props.foregroundStyle
);
return (
<Row className={this.props.containerStyle}>
<Transition
in={this.state.transitionInProp}
timeout={this.props.animationDuration}
onEntered={this.onFinishAnimatingProgressBar.bind(this)}
>
{this.renderBackground.bind(this)}
</Transition>
<Row alignItems='center' className={foregroundCss}>
{this.props.children}
</Row>
</Row>
);
}
}
const enhance = onlyUpdateForKeys([
'onFinishAnimatingProgressBar',
'fillPercent',
'endingFillPercent',
/**
* The amount of ms it takes to animate CSS properties.
* @type {number}
*/
'animationDuration',
'containerStyle',
'backgroundStyle',
'foregroundStyle',
'children',
]);
export default enhance(ProgressBar);

Things to note:

  • I need to have the starting and ending values at the same time, otherwise I can’t apply the animation correctly since it needs to be done through JS (since the mission progress is dynamic).
    • Note: if you always wanted to animate between static values, then you could still use this solution and just manually type “0%” and “100%” or something like that.
  • I have a setTimeout on the “transitionInProp” so that the animations don’t start until the component has been mounted for at least 500 ms. I had tried instead to just change “endingFillPercent” after the component had already mounted, but that turned out to be a terrible idea because a CSS transition would have already kicked off (internally in react-transition-group) as soon as “transitionInProp” is true, so the “onEntered” callback would get hit way before the progress bar finished filling up if you changed the prop mid-animation.