Browser DGAF (that you use React) Pt. 2: FLIPping in React

SiftSeptember 21, 2016

In the first post in this series, we looked at how coding within the React paradigm could lead to poor browser performance. We fixed a couple of those issues in the context of an auto-height, slide-down animation, but let’s look at the timeline we finished with:

post-slidable-update-prod

As you can see, we’re busting a lot of frames with some heavy style recalculations and layouts, and we’re definitely not hitting 60fps. The reason for this is that we’re still animating the height property, and the height property changes the position of all of its neighbors. This means that in every frame, the browser has to recalculate styles and positioning for all surrounding elements (and, in reality, every element on the page—until we have CSS containment).  Hence we’re not making 60fps—our work per frame is just too much. You can see an example of the janky animation in this Codepen:

See the Pen Slidable, no-FLIP, React by Noah Grant (@noahgrant) on CodePen.

But how do we make a slide-down animation without changing height? We FLIP it!

FLIP

FLIP stands for First, Last, Invert, and Play, and it is a technique for making animations using CSS transforms instead. Transforms, which run through the compositor, don’t affect style or flow properties of any neighboring elements, drastically reducing the amount of work the browser has to do per frame. Here’s how we use transforms to our advantage with FLIP:

  1. F: We calculate the initial position of the element we want to animate
  2. L: We move the element to its final position and measure its position
  3. I: We apply a negative transform equal to the difference between the two, which effectively makes it look like the element hasn’t moved at all
  4. P: We remove the negative transform, which, if the element has a CSS transform transition applied, will animate gracefully to its final position

In the process, we immediately render the accordion body in its entirety and bump everything below it into its own layer to be handled by the GPU. So it kinda looks like this:

accordian-blog-post

For more on FLIP, read it straight from its creator, Paul Lewis: https://aerotwist.com/blog/flip-your-animations.

An aside on the slide

Before looking at how to FLIP a slide-down animation such as the one in the CodePen, one thing to note is that, unlike the height animation, we need to translate more than just our slidable component—since transforms happen outside the flow of the document (which is why we don’t need to perform all those layouts), we also need to translate everything below the slidable. For example, say we had this DOM structure:

<section>
  <div>
    <div class="slidable">
      <div class="this-is-the-thing-to-slide-up-and-down">
        <p>some content</p>
      </div>
    </div>
    <div class="a-sibling"></div>
    <div class="another-sibling"></div>
  </div>
  <div class="bottom-part-of-the-section">
    <!-- … -->
  </div>
</section>

We would need to translate not just .this-is-the-thing-to-slid-up-and-down, but also .a-sibling, .another-sibling, and .bottom-part-of-the-section. In React, this means manipulating elements from within the Slidable component that are not within the Slidable’s component tree. Normally, React would control any style transformations, but controlling styles outside of a component’s tree is not something it’s well-equipped to do. So, to make this work, we once again have to go outside of the React paradigm.

Because Browser DGAF that you use React.

FLIP in the real (React) world

So, taking the Slidable we ended with in the previous blog post, we literally pass in the selector string for all DOM elements we want to translate as a prop:

<section>
  <div>
    <Slidable
      FLIPSelector='.a-sibling, .another-sibling, .bottom-part-of-the-section'
      updateTriggerCondition={this.state.myCondition}
    >
      {this.state.myCondition ? (
        <div className='this-is-the-thing-to-slide-up-and-down'>
          <p>some content</p>
        </div>
      ) : null}
    </Slidable>
    <div className='a-sibling'></div>
    <div className='another-sibling'></div>
  </div>
  <div className='bottom-part-of-the-section'>
    <!-- … -->
  </div>
</section>

Our Slidable’s componentDidUpdate and onTransitionEnd now look like this:

// ...
componentDidUpdate(prevProps) {
  var containerEl = React.findDOMNode(this.refs.container),
      contentHeight,
      translateHeight,
      FLIPEls;

  if (this.props.updateTriggerCondition !== prevProps.updateTriggerCondition) {
    // this.state.prevHeight is First. here’s our Last
    contentHeight = React.findDOMNode(this.refs.content).offsetHeight;
    // this is how much we are going to Invert by
    translateHeight = this.state.prevHeight - contentHeight;

    if (contentHeight !== this.state.prevHeight && !this.state.transitioning) {
      this.setState({
        height: !this.props.FLIPSelector ? this.state.prevHeight : null,
        transitioning: true
      }, () => {
        if (this.props.FLIPSelector) {
          FLIPEls = [...document.querySelectorAll(this.props.FLIPSelector)];

          FLIPEls.forEach((el) => {
            el.style.position = 'relative';
            el.style.transform = `translateY(${translateHeight}px)`;
            el.style.willChange = 'transform';
            el.style.zIndex = 100000;
          });

          containerEl.style.overflowY = 'visible';

          // sometimes the first raf called will be invoked in the same call
          // stack, which won't trigger an animation.  
          window.requestAnimationFrame(() => {
            FLIPEls.forEach((el) => {
              // and this is where we Play!
              el.style.transform = 'none';
              el.style.transition = `transform ${this.props.transitionDuration}ms`;
            });

            window.setTimeout(this.onTransitionEnd, this.props.transitionDuration);
          });
        } else {
          // by default we just animate height as before
          window.requestAnimationFrame(() => {
            window.setTimeout(this.onTransitionEnd, this.props.transitionDuration);
            this.setState({height: contentHeight});
          });
        }
      });
    }
  }
}

onTransitionEnd() {
  var childrenNumberToRemove = (this._isPreviousChildrenSecond()) ? 1 : 0,
      containerEl = React.findDOMNode(this.refs.container);

  if (this.props.FLIPSelector) {
    // unset all changed CSS on any of the FLIPped elements
    [...document.querySelectorAll(this.props.FLIPSelector)].forEach((el) => {
      el.style.position = '';
      el.style.transform = '';
      el.style.transition = '';
      el.style.willChange = '';
      el.style.zIndex = '';
    });

    containerEl.style.overflowY = '';
  }

  this.setState({
    [`children${childrenNumberToRemove}`]: null,
    height: null
  }, this.props.onChangeHeight);
}

// ...

The code isn’t very ‘React-y’, but the result sure is pretty! Take a look:

See the Pen React FLIP by Noah Grant (@noahgrant) on CodePen.

So smoove! Here’s our new, much-improved timeline:

60 FPS Timeline

Conclusion

We can take this even further and implement FLIPping for a list of accordions as shown in the graphic above, like an FAQ section or the API logs in our console. In this case, we want to translate just a select portion of the selectors we pass to <Slidable />. But I’ll leave that as an exercise for the reader.

A HUGE thank you to Paul Lewis for creating and championing this idea! I highly recommend watching his talks on FLIP and RAIL, if for no other reason than to giggle at the way he says “schedule” (he’s British).

Until next time, remember, as always: Browser DGAF.

Author