├── .gitignore ├── README.md ├── components └── stack │ └── Stack.tsx ├── next.config.js ├── package-lock.json ├── package.json ├── pages ├── _app.tsx ├── about.module.css ├── about.tsx ├── api │ └── hello.ts ├── index.module.css └── index.tsx ├── public ├── favicon.ico └── vercel.svg ├── screen.gif ├── styles └── globals.css └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | .idea 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NextJS stack transitions 2 | 3 | This repo is an example of [NextJS](https://nextjs.org/) route transitions via a [Stack component](./components/stack/Stack.tsx) 4 | whose purpose is to render flexible "how" and "when" the playIn and playOut of each 5 | page component is played. 6 | 7 | The main goal is to have the same Stack logic existing on [@cher-ami/router](https://github.com/cher-ami/router) 8 | but on nextJS file system routing. Secondary, to be able to handle route transitions 9 | without a DOM bound animation library like React Spring. This example is 10 | animated with GSAP, but could be with any other libs. 11 | 12 |
13 | screen 14 |
15 | 16 | ## How it works 17 | 18 | When the user update the browser history by clicking on "About" link, the Stack 19 | will render two pages components: 20 | 21 | ``` 22 | App 23 | |_ Stack 24 | |_ Home (prev page playOut) 25 | |_ About (current page playIn) 26 | ``` 27 | 28 | When the transition is complete, prev page is unmount. 29 | 30 | ``` 31 | App 32 | |_ Stack 33 | |_ About 34 | ``` 35 | 36 | In order to do this, each page register for the parent Stack component a playIn and playOut 37 | function + the DOM root element via `useImperativeHandle`. The Stack can thus be able to access 38 | these properties. 39 | 40 | ```jsx 41 | const Home = forwardRef((props, handleRef) => { 42 | const $root = useRef(null) 43 | useImperativeHandle(handleRef, () => ({ 44 | playIn: () => 45 | gsap.timeline().fromTo( 46 | $root.current, 47 | { autoAlpha: 0 }, 48 | { autoAlpha: 1 } 49 | ), 50 | playOut: () => 51 | gsap.timeline().to($root.current, { 52 | autoAlpha: 0, 53 | }), 54 | $root: $root.current, 55 | })) 56 | }) 57 | ``` 58 | 59 | Otherwise, the root App component will manage the transitions function added to Stack component by props. 60 | We can now control the scenario with previous and current page components \o/ 61 | 62 | ```jsx 63 | function App({ Component, pageProps }) { 64 | const custom = useCallback( 65 | ({ prev, current }) => 66 | new Promise(async (resolve) => { 67 | // playOut prev page component 68 | if (prev) await prev.playOut?.() 69 | // when playOut is complete, playin new current page component 70 | await current.playIn?.() 71 | resolve() 72 | }), 73 | [] 74 | ) 75 | return ( 76 |
77 | 78 |
79 | ) 80 | } 81 | ``` 82 | 83 | ## Test it online 84 | 85 | [nextjs-stack-transitions.vercel.app](https://nextjs-stack-transitions-dpbcjaqqi-willybrauner.vercel.app) 86 | 87 | ## Install the example 88 | 89 | Clone the repos and move to the cloned repository: 90 | 91 | ```shell 92 | git clone && cd nextjs-route-stack-transitions-example 93 | ``` 94 | 95 | Install dependencies: 96 | 97 | ```shell 98 | npm i 99 | # or 100 | yarn 101 | ``` 102 | 103 | Run the nextJS dev-server: 104 | 105 | ```shell 106 | npm run dev 107 | ``` 108 | 109 | ## Credits 110 | 111 | Willy Brauner 112 | -------------------------------------------------------------------------------- /components/stack/Stack.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useReducer, useLayoutEffect, useEffect, memo } from "react" 2 | import { NextComponentType, NextPageContext } from "next" 3 | import { Router } from "next/router" 4 | 5 | export interface IRouteStack { 6 | componentName?: string 7 | playIn?: () => Promise 8 | playOut?: () => Promise 9 | $root?: HTMLElement 10 | } 11 | 12 | export interface TCustomTransitionsParams { 13 | prev: IRouteStack 14 | current: IRouteStack 15 | unmountPrev: () => void 16 | } 17 | 18 | export interface IProps { 19 | Component: NextComponentType 20 | customTransitions: (T: TCustomTransitionsParams) => Promise 21 | pageProps 22 | } 23 | 24 | const useIsomorphicLayoutEffect = 25 | typeof window !== "undefined" && window.document?.createElement ? useLayoutEffect : useEffect 26 | 27 | /** 28 | * Stack component 29 | * @param props 30 | */ 31 | function Stack(props: IProps): JSX.Element { 32 | const $prevRef = useRef(null) 33 | const $currentRef = useRef(null) 34 | 35 | const componentReducer = ( 36 | state: { 37 | prev?: NextComponentType 38 | prevPageProps? 39 | current?: NextComponentType 40 | pageProps? 41 | count: number 42 | }, 43 | action: { type: "update" | "unmount-prev"; component?; pageProps? } 44 | ) => { 45 | switch (action.type) { 46 | case "update": 47 | return { 48 | ...state, 49 | prev: state.current, 50 | prevPageProps: state.pageProps, 51 | 52 | current: action.component, 53 | pageProps: action.pageProps, 54 | count: state.count + 1, 55 | } 56 | case "unmount-prev": 57 | return { 58 | ...state, 59 | prev: null, 60 | prevPageProps: null, 61 | } 62 | default: 63 | throw new Error() 64 | } 65 | } 66 | 67 | const [state, dispatch] = useReducer(componentReducer, { 68 | prev: null, 69 | prevPageProps: null, 70 | current: props.Component, 71 | pageProps: props.pageProps, 72 | count: 0, 73 | }) 74 | 75 | // --------------------------------------------------------------------------- NEW COMPONENT 76 | 77 | // 1. each time stack get new Component as props 78 | // update global reducer state 79 | const firstRender = useRef(true) 80 | useIsomorphicLayoutEffect(() => { 81 | if (firstRender.current) { 82 | firstRender.current = false 83 | return 84 | } 85 | 86 | if (!props.Component) return 87 | dispatch({ 88 | type: "update", 89 | component: props.Component, 90 | pageProps: props.pageProps, 91 | }) 92 | }, [props.Component, props.pageProps]) 93 | 94 | // --------------------------------------------------------------------------- PATCH 95 | 96 | /** 97 | * NextJS will remove module.css property from HTML document when route change 98 | * This hake allows to copy this CSS to avoid page style clip on page play-out transition 99 | * MAXI tricky but no choice. Farmer Motion got the same issue 100 | * https://github.com/vercel/next.js/discussions/18724#discussioncomment-967618 101 | * 102 | */ 103 | const copies = useRef([]) 104 | const onLoad = (): void => { 105 | resetStyleCopies() 106 | // Create a clone of every