Sliding Pages With React.js

Sliding Pages With React.js

A step by step guide to writing your own sliding pages router

By Asaf Gur, Published 2018-07-03

Make Them Slide!

The popular way to achieve routing in React is using React Router v4. React Router works great, but it doesn’t make the pages slide. Of course, you can use a package like React Router Page Transition to make that happen.
We are not going to use either of that packages in this tutorial. Instead, we will roll our own and create a sliding pages router.
This tutorial is for learning purposes, If you want to use the final code in production it would require some more work.
The complete solution is on github and you can find it here

Prerequisites

You should be comfortable with HTML, Javascript, css and React.

Step 1 - Setup

we’ll use create-react-app. If you don’t have React installed, install it.

Let’s create our new app:

$ create-react-app react-sliding-pages

Alright! First step complete! Feel free to delete logo.svg, we won’t be using it.
You can cd into the project’s directory and run npm start, so you can watch your progress.

Step 2 - Creating The Page Element

Create a new file called page.js under the src directory.

page.js


import React, { Component } from 'react';


class Page extends Component {

    constructor(props) {
        super(props);
    }


    render() {
        return (
            <div style={{
                width: '100vw', 
                height: '100%',
                position: 'fixed',
                top: 0,
                left: 0,
                transform: 'translateX('+ this.props.left + 'px)',
                animationTimingFunction: 'ease-in',
                zIndex: -20,
                transition: 'transform .8s ease-in-out'
            }}>
                {this.props.children}
            </div>
        );

    }

}

export default Page;


There’s a few things to pay attention to in this component.
First of all, this is a container component — we use this.props.children to render all child components.
The other two are in the css:
The transformX property is determined by the left prop.
The transition and animationTimingFunction controls the animation and makes the sliding happen.

Now that we have our page component, we should try to use it.

Step 3 - Sliding The Page

Let’s take care of some crucial styling. Change the contents of App.css to this:

App.css


.App {
  text-align: center;
}

.page {
  width: 300px;
  height: 100%;
  min-height: 5em;
  margin-right: auto;
  margin-left: auto;
  margin-top: 5em;
  padding: 2em;
  z-index: 15000;
}

Change the contents of App.js to:

App.js


import React, { Component } from 'react';
import './App.css';
import Page from './page';

class App extends Component {

  constructor(props) {
    super(props);
    this.state = {left: 0}
  }


  render () {
    return (
      <div className='App'>
        <div>
          <input type="text" onChange={(e) => this.setState({left: parseInt(e.target.value)})} />
        </div>
        <Page left={this.state.left}>
          <div className='page' style={{ backgroundColor: 'green' }}>
            bla1
          </div>
        </Page>
      </div>
    )
  }
}

export default App

Play around with the input, try typing 1000 or -500 and see what happens. If everything went well, the page should slide across the screen.
So what did we do here?
We created a state variable called left and passed it to our page component. When the input changes, it triggers the setState method and changes the page’s left property.
That’s nice, but it’s only one page. We need an element that will wrap our page components and switch them.

Step 4 - Creating The Nav Element

This is the main part of our program and where the most logic is. Let’s create our Nav element in small parts.

We’ll start with an empty component. Create a new file called nav.js under the src directory.
nav.js


import React, { Component } from 'react';


class Nav extends Component {

}

export default Nav;

The plan is to place the current page in the middle of the screen and hide the other pages on the sides of the screen. To achieve that, we’ll need to know the screen width. The left prop worked well before, so we’ll use it here as well.

Let’s create the constructor for the Nav component:

nav.js


...

    constructor(props) {
        super(props);
        this.width = window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth;
        let left = props.children.map(o => this.width);
        if (left.length > 0 ) {
            left[0] = 0;
        }
        this.state = {page: 0, left: left};        
    }

...

We now have the screen’s width in this.width, an array of integers that represents the pages’ left property and a page that represents the current page number.

Now let’s add the move method:

nav.js

...

    move(page) {
        const left = this.state.left.slice();
        if (page >= left.length) {
          page = 0;
        }
        for (let i = 0; i < left.length; i++ ) {
          if (i < page) {
            left[i] = -this.width;
          } else if (i === page) {
            left[i] = 0;
          } else {
            left[i] = this.width;
          }
        }    
        this.setState({left: left, page: page})
      }
      
...

Take a moment to read the code and understand what it does.

Basically, the code iterates on a copy of the left array and sets each page’s left location according to the page number.

Let’s continue to the render method.

nav.js

...

    render() {
        const pageElements = React.Children.map(this.props.children, (page, idx) =>
            React.cloneElement(page, { left: this.state.left[idx] })); 
        return (
            <div>
                {pageElements}
            </div>
        );
    }

...

Here, we use React.Children.map to iterate over the page elements and add the left prop.

Now let’s use our new Nav element in App.js.

App.js


import React, { Component } from 'react';
import './App.css';
import Page from './page';
import Nav from './nav';

class App extends Component {

  constructor(props) {
    super(props);
  }



  render() {
    return (
      <div className="App">
        <Nav>
          <Page>
            <div className="page" style={{ backgroundColor: 'green' }}>
              bla1
            </div>
          </Page>
          <Page>
            <div className="page" style={{ backgroundColor: 'red' }}>
              bla1
            </div>
          </Page>
          <Page>
            <div className="page" style={{ backgroundColor: 'blue' }}>
              bla1
            </div>
          </Page>
          <Page>
            <div className="page" style={{ backgroundColor: 'brown' }}>
              bla1
            </div>
          </Page>
        </Nav>
        
      </div>
    );
  }
}

export default App;


In case everything worked well, you should see the first page rendered in your browser. You can check the html source (using the browser’s dev tools) and see that all the child elements are indeed rendered.

Alright, so we have all the pages but no access to the move method. Let’s add some buttons temporarily just to see that the slide works.

nav.js

...

    render() {
        const pageElements = React.Children.map(this.props.children, (page, idx) =>
            React.cloneElement(page, { left: this.state.left[idx] })); 
        const buttonElements = React.Children.map(this.props.buttons, (button, idx) => {
            let newButton = React.cloneElement(button , { onClick: () => this.move(idx), ...button.props});
            return newButton;
        });
        return (
            <div>
                <div>
                    {buttonElements}
                </div>
                <div>
                    {pageElements}
                </div>
            </div>
        );
    }

...

App.js

...

  render() {
    let buttons = [];
    for (let i = 0; i < 4; i++) {
      let button = <button key={i.toString()}>{i}</button>;
      buttons.push(button);
    }
    return (
      <div className="App">
        <Nav buttons={buttons}>
          <Page>
            <div className="page" style={{ backgroundColor: 'green' }}>
              bla1
            </div>
          </Page>
          <Page>
            <div className="page" style={{ backgroundColor: 'red' }}>
              bla1
            </div>
          </Page>
          <Page>
            <div className="page" style={{ backgroundColor: 'blue' }}>
              bla1
            </div>
          </Page>
          <Page>
            <div className="page" style={{ backgroundColor: 'brown' }}>
              bla1
            </div>
          </Page>
        </Nav>
        
      </div>
    );
  }

...

Try the buttons in your browser, the pages should slide.

Now that the basic functionality works, let’s make our Nav element more router like.

Step 5 - Implement Routing

First, remove the buttons we used for testing. We don’t need them. With that out of the way, create a new file called link.js under the src directory.

link.js


import React, { Component } from 'react';


class Link extends Component {

    go() {
        window.location.hash = this.props.to.slice(1);
    }

    render() {
        return (
            <i onClick={() => this.go()}>
                {this.props.children}
            </i>

        );
    }

}

export default Link;

This component is pretty straight forward, it renders the component’s children and changes the hash on a click according to the to prop.

Let’s make our Nav component react to hash changes.

nav.js

...

    constructor(props) {
        super(props);
        this.width = window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth;
        let left = props.children.map(o => this.width);
        if (left.length > 0 ) {
            left[0] = 0;
        }
        this.state = {page: 0, left: left, from: left};
        this.pages = {};
    }

...

    componentDidMount() {
        this.move(this.pages[window.location.hash.slice(1)]);
    }

    componentWillMount() {
        window.onhashchange = (e) => {
            const i = e.newURL.indexOf('#');
            const hash = e.newURL.slice(i + 1);
            this.move(this.pages[hash]);
        };
    }


    render() {
        const pageElements = React.Children.map(this.props.children, (page, idx) => {
            const element = React.cloneElement(page, { left: this.state.left[idx], from: this.state.from[idx], eventer:this.eventer }); 
            this.pages[element.props.path.slice(1)] = idx;
            return element;
        });
        return (
            <div>
                {pageElements}
            </div>
        );
    }

...

As you can see we, are registering the pages to the pages prop, so we can know which route belongs to which page.

And finally use our new links in App.js.

App.js


import React, { Component } from 'react';
import './App.css';
import Page from './page';
import Nav from './nav';
import Link from './link';

class App extends Component {

  render() {
    return (
      <div className="App">
        <div>
          <Link to="/green" >
            <button>green</button>
          </Link>
          <Link to="/red" >
            <button>red</button>
          </Link>
          <Link to="/blue" >
            <button>blue</button>
          </Link>
          <Link to="/brown" >
            <button>brown</button>
          </Link>
        </div>
        <Nav>
          <Page path="/green">
            <div className="page" style={{ backgroundColor: 'green' }}>
              bla1
            </div>
          </Page>
          <Page path="/red">
            <div className="page" style={{ backgroundColor: 'red' }}>
              bla1
            </div>
          </Page>
          <Page path="/blue">
            <div className="page" style={{ backgroundColor: 'blue' }}>
              bla1
            </div>
          </Page>
          <Page path="/brown">
            <div className="page" style={{ backgroundColor: 'brown' }}>
              bla1
            </div>
          </Page>
        </Nav>
        
      </div>
    );
  }
}

export default App;

Alright! We have a sliding router! But wait…
The pages that are off screen are still rendered. This might be good for you, if your app is pretty much static. But as we know, React components might do a whole lot of stuff.. Use the the network, do some calculations, animations… etc. We need some way to disable the component that are off screen.

Step 6 - Only Render What Is Visible

This part is a bit tricky, we need the pages visible for the sliding effect but we don’t want them to be rendered when they are off screen. For example, if the user goes from page 5 to page 1, we need to render all the pages for the sliding effect and then only render page 1.

We’ll use an event mechanism, that will fire every time the page changes.
Add another class to nav.js .

nav.js

...

class MoveEvent {

    constructor() {
        this.listeners = [];
    }

    register(f) {
        this.listeners.push(f);
    }

    unregister(f) {
        this.listeners.splice(this.listeners.indexOf(f), 1);
    }

    fire(params) {
        this.listeners.forEach((listener) => {
            listener(params);
        });

    }
}


...

This is a pretty standard event class. We will also add a from prop, so that the page element will be able to tell if it’s in transition or not. Let’s pass the props to our page components from our Nav component.

nav.js

...

class Nav extends Component {

    constructor(props) {
        super(props);
        this.width = window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth;
        let left = props.children.map(o => this.width);
        if (left.length > 0 ) {
            left[0] = 0;
        }
        this.state = {page: 0, left: left, from: left};
        this.pages = {};
        this.eventer = new MoveEvent();
    }

    move(page) {
        const left = this.state.left.slice();
        const from = left.slice();
        if (page >= left.length) {
          page = 0;
        }
        for (let i = 0; i < left.length; i++ ) {
          if (i < page) {
            left[i] = -this.width;
          } else if (i === page) {
            left[i] = 0;
          } else {
            left[i] = this.width;
          }
        }    
        this.setState({left: left, page: page, from: from});
        this.eventer.fire();
      }
      
    componentDidMount() {
        this.move(this.pages[window.location.hash.slice(1)]);
    }

    componentWillMount() {
        window.onhashchange = (e) => {
            const i = e.newURL.indexOf('#');
            const hash = e.newURL.slice(i + 1);
            this.move(this.pages[hash]);
            this.eventer.fire();
        };
    }


    render() {
        const pageElements = React.Children.map(this.props.children, (page, idx) => {
            const element = React.cloneElement(page, { left: this.state.left[idx], from: this.state.from[idx], eventer:this.eventer }); 
            this.pages[element.props.path.slice(1)] = idx;
            return element;
        });
        return (
            <div>
                {pageElements}
            </div>
        );
    }

}


...

Great, Now let’s make the page understand if it needs to render children or not.

page.js


import React, { Component } from 'react';

function sleep(ms) { // just a sleep util
    return new Promise(resolve => setTimeout(resolve, ms));
}

class Page extends Component {

    constructor(props) {
        super(props);
        this.state = {shouldRenderChildren: true};
    }

    isVisible() {
        if (this.props.left < 0) {
            return false;
        }
        if (this.props.left >= window.innerWidth) {
            return false;
        }
        return true;
    }

    move() {
        if (this.props.left !== this.props.from) {
            this.setState({ shouldRenderChildren: true });
        }
        this.reRender();
    }

    async reRender() {
        await sleep(1000);
        this.setState({shouldRenderChildren: this.isVisible()});
    }

    render() {
        return (
            <div
             style={{
                width: '100vw', 
                height: '100%',
                position: 'fixed',
                top: 0,
                left: 0,
                transform: 'translateX('+ this.props.left + 'px)',
                animationTimingFunction: 'ease-in',
                zIndex: -20,
                transition: 'transform .8s ease-in-out'
            }}> {this.state.shouldRenderChildren &&
                    this.props.children
                 }
            </div>
        );

    }

}

export default Page;

...

The isVisible method returns whether or not the page is off screen. The reRender method sleeps for 1 second and updates the shouldRenderChildren state variable. When move is called the element will be fully rendered for 1 second and then it will disappear if it’s off screen.

Now we just need to register to the move event.

page.js

...

    componentDidMount() {
        this.props.eventer.register(() => this.move());
    }

    componentWillUnmount() {
        this.props.eventer.unregister(() => this.move());
    }

...

Awesome! Check your browser dev tools and see that off screen pages are not rendering any children.

Step 7 (optional) - Customizing And Extending The Code

There’s a chance that your app needs the pages to behave differently.
For example, you can render each page’s child components and pass them a prop telling them if they are visible or not. Whatever comes to mind really.
That part is totally up to you!
If you are extending or customizing the code, I would love to hear about your ideas and implementations.

Summary

We created our own router with sliding pages, that’s pretty cool. Of course our router is no match for React Router, which is much more fancy and also battle tested. Rolling your own can be a huge pain sometimes(no one likes reinventing the wheel), but it has a big advantage - you can code exactly what your app needs.
I hope you enjoyed, and of course, feel free to comment or share.
Thank you for reading.