@moxy/react-page-swapper

An orchestrator that eases out the implementation of page transitions

Usage no npm install needed!

<script type="module">
  import moxyReactPageSwapper from 'https://cdn.skypack.dev/@moxy/react-page-swapper';
</script>

README

react-page-swapper

NPM version Downloads Build Status Coverage Status Dependency status Dev Dependency status

An orchestrator that eases out the implementation of page transitions.

Installation

$ npm install @moxy/react-page-swapper

This library is written in modern JavaScript and is published in both CommonJS and ES module transpiled variants. If you target older browsers please make sure to transpile accordingly.

Motivation

Adding page transitions to your website might look easy at first glance. There are a plethora of articles on the web that suggest using libraries such as <TransitionGroup> from React Transition Group or <AnimatePresence> from Framer's Motion, to add page transitions to your website.

However, they are generic solutions and, as a result, they miss important steps for page transitions. Amongst others, one of the most important steps they miss out is the scroll position. You want your page transitions to work nicely regardless of the scroll being at the top or at the bottom.

So, what makes a good page transition library? Here's the fundamental steps to take while swapping pages:

  1. Remove the current page from the normal flow of the document, while keeping it exactly in same position and with the same dimensions. This usually involves making it position: fixed and set top, left, width and height CSS properties correctly.
  2. Lock the container dimensions by setting min-width and min-height accordingly. This is needed to maintain the container dimensions since the current page is out of the flow, meaning it will no longer grow its parent.
  3. Render the new page, making it part of the normal flow of the document.
  4. Update the scroll position and unlock the container dimensions that were previously set in step 2.. Updating the scroll position usually means doing window.scrollTo(0, 0) on a new navigation (coming from history.pushState) or restoring the scroll position on a popstate.
  5. Play the animations, orchestrating the exit and enter transitions of the current and new page respectively.
  6. Unmount the current page from the DOM once both animations finish. The new page has now become the current page.

@moxy/react-page-swapper offers a <PageSwapper> component that performs all the steps mentioned above, effectively orchestrating the swapping of pages. Note, however, that it doesn't actually animate your pages and instead lets you use your favorite animation library, given you respect the established API.

Demo

You may see a simple demo of @moxy/react-page-swapper at https://moxystudio.github.io/react-page-swapper.

Usage

Here's a quick example of how you would use it in a Next.js app along with <CSSTransition> from React Transition Group:

// pages/_app.js
import React from 'react';
import PageSwapper from '@moxy/react-page-swapper';
import { CSSTransition } from 'react-transition-group';
import styles from './_app.module.css';

if (typeof history !== 'undefined') {
    history.scrollRestoration = 'manual';
}

const App = ({ Component, pageProps }) => (
    <PageSwapper
        node={ <Component { ...pageProps } /> }
        animation="fade">
        { ({ animation, style, in: inProp, onEntered, onExited, node }) => (
            <CSSTransition
                className={ styles[animation] }
                style={ style }
                in={ inProp }
                onEntered={ onEntered }
                onExited={ onExited }>
                <div>{ node }</div>
            </CSSTransition>
        ) }
    </PageSwapper>
);

export default App;
/* pages/_app.module.css */
.fade {
    transition: opacity 0.6s;

    &.enter {
        opacity: 0;
    }

    &.enterActive,
    &.enterDone {
        opacity: 1;
    }
}

Caveats

Prevent overflow in the container element

If you have horizontal / vertical animations, make sure to prevent elements from overflowing the container. Here's an example to disable horizontal overflow:

<PageSwapper
  /* other props */
  style={ { width: '100%', overflowX: 'hidden' } }>
  { () => (/* */) }
</PageSwapper>

Alternatively, you may pass a className that has the same CSS declarations.

Focus handling

The current focused element will be automatically blurred to to prevent animations from glitching. However, it's a good accessibility practice to focus the primary element within the new page.

To focus elements after a swap is completed, you have two options:

  1. Use the onSwapEnd prop:
const handleSwapEnd = useMemo(() => {
    document.querySelector('[data-focusable-page-element]')?.focus();
}, []);

<PageSwapper
  /* other props */
  onSwapEnd={ handleSwapEnd }>
  { () => (/* */) }
</PageSwapper>

...and then add the [data-focusable-page-element] and tabIndex="-1" (if needed) attributes to the element, of each page, that should be immediately focused.

  1. Use the transitioning property of the children render prop:
<PageSwapper
  /* other props */>
  { ({ style, in: inProp, transitioning, onEntered, onExited }) => (
      <CSSTransition
          style={ style }
          in={ inProp }
          onEntered={ onEntered }
          onExited={ onExited }>
          <div>
              /* This is the secret sauce */
              { cloneElement(node, { focus: inProp && !transitioning }) }
          </div>
      </CSSTransition>
  ) }
</PageSwapper>

...and then handle the focus property within your pages' components:

const MyPage = ({ focus }) => {
    const focusableRef = useRef();

    useEffect(() => {
        if (focus) {
            focusableRef.current?.focus();
        }
    }, [focus]);

    return (
        <div>
          <h1 tabIndex="-1" ref={ focusableRef }>Page title</h1>
          <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit</p>
        </div>
    );
};
Glitchy animations

As a rule of thumb, use CSS properties that only require composite, such as opacity and transform. Properties such as top and left require layout which are often less performant, thus you should avoid them. You may use CSS Triggers to check which CSS properties cause layout, paint and composite.

If you are still experiencing glitchy animations, read the list below for possible solutions:

  1. Stuttering animations in Firefox

Try adding backface-visibility: hidden to the element. If that doesn't work, try adding transform-style: preserve-3d or transform: translateZ(0) instead.

  1. Flicker in iOS Safari

Sometimes, the current page flickers right before the out animation. This is a known iOS Safari issue when transform is used in combination with position: fixed.

First, try promoting the layer to the GPU with the usual transform: translateZ(0) "hacks". If these don't work, then changing transform to left and top (or similar) will most likely fix the problem. Since the flicker is caused by the browser delaying the composite calculations, using CSS properties that cause layout will force them to be applied earlier.

To apply this trade-off only for iOS Safari, you may perform device detection with JavaScript or use the @supports like so:

@supports not (-webkit-touch-callout: none) {
    /* Target all browsers except iOS Safari */
}

@supports (-webkit-touch-callout: none) {
    /* Target only iOS Safari */
}

⚠️ If you are indeed using top and left, they will conflict with the style property from the render prop function. One way to circumvent this is to create a wrapper and apply the style property to that element instead.

⚠️ The @supports CSS rule is not supported in Internet Explorer.

API

<PageSwapper>

<PageSwapper> is the default export and is a component that orchestrates the swapping of pages.

ℹ️ Besides the props described below, any other props will be spread into the container element, allowing you to specify DOM props such as className.

node

Type: ReactElement

In simple scenarios, this is the page's react element.

In advanced scenarios, such as nested routes, node is a node from a react tree. Usually, the leaf node is the page element and non-leaf nodes are layout elements.

nodeKey

Type: string (required)
Default: random but deterministic

A unique key that identifies the node. If omitted, a random key node will be generated based on the node's component type. In advanced scenarios, you may specify a key such as one based on the route path or location.pathname. You may take a look at getNodeKeyFromPathname() to see if it's useful for your use-case.

mode

Type: string or Function
Default: simultaneous

The mode in which the swap will occur, which can be set to simultaneous or out-in.

When mode is simultaneous, the current node will transition out at the same time as the new node will transition in. In contrast, when mode is out-in, the current node will transition out first and only then the new node will be mounted and transition in. It may be a fixed string or a function to determine it, with the following signature:

({ nodeKey, prevNodeKey }) => mode;

The function form allows you to select the mode based on the current and previous node keys, making it possible to choose different modes depending on the context.

animation

Type: string or Function

The animation to use when transitioning the current node out and the new one in. It may be a fixed string or a function to determine it, with the following signature:

({ nodeKey, prevNodeKey }) => animation;

The function form allows you to select the animation based on the current and previous node keys, making it possible to choose different animations depending on the context.

children

Type: Function (required)

A render prop that is called for exiting and entering nodes, with the correct context. It has the following signature:

({ node, nodeKey, animation, style, transitioning, in, onEntered, onExiting }) => ReactElement;
Property Type Description
node ReactElement The node to render.
nodeKey string The key associated to the node.
prevNodeKey string The key associated to the previous node, if any.
mode string The swap mode, either simultaneous or out-in.
animation string The animation to apply for the transition.
style Object An object with CSS styles to be applied to the element being transitioned.
transitioning boolean True if the node is transitioning, false otherwise. See note below.
in boolean True to show the node, false otherwise.
onEntered Function Function to be called when the node finishes transitioning in.
onExited Function Function to be called when the node finishes transitioning out.

If you are familiar with <Transition> and <CSSTransition> components from React Transition Group, the in, onEntered and onExited should be familiar to you.

The style property contains inline styles, namely position: fixed with top, left, width and height for pages that are exiting. Be sure to apply these to the element being transitioned.

The transitioning property makes it possible to know if the node has finished transitioning or not, which is useful to disable behavior while the animation is playing, like ignoring scroll events or IntersectionObserver callbacks.

updateScroll

Type: Function
Default: ({ nodeKey }) => window.scrollTo(0, 0)

A function called to update the scroll position during a swap. Usually, you do window.scrollTo(0, 0) on a new navigation (coming from history.pushState) or restore the scroll position on a popstate.

We recommend using scroll-behavior to integrate with the Router you are using, and pass () => scrollBehavior.updateScroll() as the updateScroll property.

If you are building your application on top of Next.js then you may want to integrate this property with next-scroll-behavior.

onSwapBegin

Type: Function

A callback called whenever a swap begins, with the following parameters:

({ nodeKey, nextNodeKey }) => {}

onSwapEnd

Type: Function

A callback called whenever a swap ends, with the following parameters:

({ nodeKey, prevNodeKey }) => {}

getNodeKeyFromPathname(level, [pathname])

A utility that returns a slice of location.pathname. Useful if you want to have fine grained control over nodeKey.

import { getNodeKeyFromPathname } from '@moxy/react-page-swapper';

// Given `location.pathname` equal to `/foo/bar/baz`:

getNodeKeyFromPathname(0) // /foo
getNodeKeyFromPathname(1) // /foo/bar
getNodeKeyFromPathname(2) // /foo/bar/baz

You may specify a custom pathname, like a route path:

import { getNodeKeyFromPathname } from '@moxy/react-page-swapper';

getNodeKeyFromPathname(0, '/blog/[id]') // /blog
getNodeKeyFromPathname(1, '/blog/[id]') // /blog/[id]

⚠️ Specifying the pathname is a must when using certain frameworks. One example is Next.js, where you must use router.asPath, otherwise <PageSwapper /> will begin swapping too soon, causing a swap to the same node.

isHistoryEntryFromPopState()

A utility to know if the current history entry originated from a popstate event or not. Useful to disable animations if the user is using the browser's back and forward functionality.

// pages/_app.js
import { isHistoryEntryFromPopState } from '@moxy/react-page-swapper';

const animation = isHistoryEntryFromPopState() ? 'none' : 'fade';

// and then code the 'none' animation to be a dummy one that finishes instantly

Tests

$ npm test
$ npm test -- --watch # during development

License

Released under the MIT License.