Animated page transitions in react Animated page transitions in react reactjs reactjs

Animated page transitions in react


Edit: Added a working example

https://lab.award.is/react-shared-element-transition-example/

(Some issues in Safari for macOS for me)


The idea is to have the elements to be animated wrapped in a container that stores its positions when mounted. I created a simple React Component called SharedElement that does exactly this.

So step by step for your example (Overview view and Detailview):

  1. The Overview view gets mounted. Each item (the squares) inside the Overview is wrapped in the SharedElement with a unique ID (for example item-0, item-1 etc). The SharedElement component stores the position for each item in a static Store variable (by the ID you gave them).
  2. You navigate to the Detailview. The Detailview is wrapped into another SharedElement that has the same ID as the item you clicked on, so for example item-4.
  3. Now this time, the SharedElement sees that an item with the same ID is already registered in its store. It will clone the new element, apply the old elements position to it (the one from the Detailview) and animates to the new position (I did it using GSAP). When the animation has completed, it overwrites the new position for the item in the store.

Using this technique, it's actually independent from React Router (no special lifecycle methods but componentDidMount) and it will even work when landing on the Overview page first and navigating to the Overview page.

I will share my implementation with you, but be aware that it has some known bugs. E.g. you have to deal with z-indeces and overflows yourself; and it doesn't handle unregistering element positions from the store yet. I'm pretty sure if someone can spend some time on this, you can make a great little plugin out of it.

The implementation:

index.js

import React from "react";import ReactDOM from "react-dom";import App from "./App";import Overview from './Overview'import DetailView from './DetailView'import "./index.css";import { Router, Route, IndexRoute, hashHistory } from 'react-router'const routes = (    <Router history={hashHistory}>        <Route path="/" component={App}>            <IndexRoute component={Overview} />            <Route path="detail/:id" component={DetailView} />        </Route>    </Router>)ReactDOM.render(    routes,    document.getElementById('root'));

App.js

import React, {Component} from "react"import "./App.css"export default class App extends Component {    render() {        return (            <div className="App">                {this.props.children}            </div>        )    }}

Overview.js - Note the ID on the SharedElement

import React, { Component } from 'react'import './Overview.css'import items from './items' // Simple array containing objects like {title: '...'}import { hashHistory } from 'react-router'import SharedElement from './SharedElement'export default class Overview extends Component {    showDetail = (e, id) => {        e.preventDefault()        hashHistory.push(`/detail/${id}`)    }    render() {        return (            <div className="Overview">                {items.map((item, index) => {                    return (                        <div className="ItemOuter" key={`outer-${index}`}>                            <SharedElement id={`item-${index}`}>                                <a                                    className="Item"                                    key={`overview-item`}                                    onClick={e => this.showDetail(e, index + 1)}                                >                                    <div className="Item-image">                                        <img src={require(`./img/${index + 1}.jpg`)} alt=""/>                                    </div>                                    {item.title}                                </a>                            </SharedElement>                        </div>                    )                })}            </div>        )    }}

DetailView.js - Note the ID on the SharedElement

import React, { Component } from 'react'import './DetailItem.css'import items from './items'import { hashHistory } from 'react-router'import SharedElement from './SharedElement'export default class DetailView extends Component {    getItem = () => {        return items[this.props.params.id - 1]    }    showHome = e => {        e.preventDefault()        hashHistory.push(`/`)    }    render() {        const item = this.getItem()        return (            <div className="DetailItemOuter">                <SharedElement id={`item-${this.props.params.id - 1}`}>                    <div className="DetailItem" onClick={this.showHome}>                        <div className="DetailItem-image">                            <img src={require(`./img/${this.props.params.id}.jpg`)} alt=""/>                        </div>                        Full title: {item.title}                    </div>                </SharedElement>            </div>        )    }}

SharedElement.js

import React, { Component, PropTypes, cloneElement } from 'react'import { findDOMNode } from 'react-dom'import TweenMax, { Power3 } from 'gsap'export default class SharedElement extends Component {    static Store = {}    element = null    static props = {        id: PropTypes.string.isRequired,        children: PropTypes.element.isRequired,        duration: PropTypes.number,        delay: PropTypes.number,        keepPosition: PropTypes.bool,    }    static defaultProps = {        duration: 0.4,        delay: 0,        keepPosition: false,    }    storeNewPosition(rect) {        SharedElement.Store[this.props.id] = rect    }    componentDidMount() {        // Figure out the position of the new element        const node = findDOMNode(this.element)        const rect = node.getBoundingClientRect()        const newPosition = {            width: rect.width,            height: rect.height,        }        if ( ! this.props.keepPosition) {            newPosition.top = rect.top            newPosition.left = rect.left        }        if (SharedElement.Store.hasOwnProperty(this.props.id)) {            // Element was already mounted, animate            const oldPosition = SharedElement.Store[this.props.id]            TweenMax.fromTo(node, this.props.duration, oldPosition, {                ...newPosition,                ease: Power3.easeInOut,                delay: this.props.delay,                onComplete: () => this.storeNewPosition(newPosition)            })        }        else {            setTimeout(() => { // Fix for 'rect' having wrong dimensions                this.storeNewPosition(newPosition)            }, 50)        }    }    render() {        return cloneElement(this.props.children, {            ...this.props.children.props,            ref: element => this.element = element,            style: {...this.props.children.props.style || {}, position: 'absolute'},        })    }}


I actually had a similar problem, where I had a search bar and wanted it to move and wrap to a different size and place on a specific route (like a general search in the navbar and a dedicated search page). For that reason, I created a component very similar to SharedElement above.

The component expects as props, a singularKey and a singularPriority and than you render the component in serval places, but the component will only render the highest priority and animate to it.

The component is on npm as react-singular-compomentAnd here is the GitHub page for the docs.