Skip to content

Instantly share code, notes, and snippets.

@RakaDoank
Last active August 20, 2023 11:36
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save RakaDoank/f22e949e5ed42cce6786c742b043cae9 to your computer and use it in GitHub Desktop.
Save RakaDoank/f22e949e5ed42cce6786c742b043cae9 to your computer and use it in GitHub Desktop.
Temporary Solution for Listening to the Router Events in Next.js 13. Automatically listen only for next/link component.

I really want to show top-bar-ish animation in my web when client is requesting a new page by clicking a link or programatically router.push or router.replace, but Next.js dev decided to remove the router events in Next.js 13.

So, i ended up by listening to the document click event. It's inspired by nextjs-toploader, but it only works for next/link component, not programmatically like router.push and router.replace

Better than nothing. I created a my own react context for the top-bar loader, and also have functions to programatically start and stop

The idea of the document click event is listening to the document click and detect if the anchor element (next/link component) has been clicked.

'use client'

import {
  useEffect,
  useRef,

  createContext,
  memo,
} from 'react'

import {
  usePathname,
  useSearchParams,
} from 'next/navigation'

import * as LoaderLego from '@/components/legos/loader'



export interface PageLoaderAnimationContextInterface {
  start: () => void,
  stop: () => void,
}

const PageLoaderAnimationContext = createContext({} as PageLoaderAnimationContextInterface)

export default PageLoaderAnimationContext



function findClosestAnchor(element: HTMLElement | null): HTMLAnchorElement | null {
  while(element && element.tagName.toLowerCase() !== 'a') {
    element = element.parentElement
  }
  return element as HTMLAnchorElement
}

function isAnchorOfCurrentUrl(currentUrl: string, newUrl: string) {
  const currentUrlObj = new URL(currentUrl);
  const newUrlObj = new URL(newUrl);
  // Compare hostname, pathname, and search parameters
  if (
    currentUrlObj.hostname === newUrlObj.hostname &&
    currentUrlObj.pathname === newUrlObj.pathname &&
    currentUrlObj.search === newUrlObj.search
  ) {
    // Check if the new URL is just an anchor of the current URL page
    const currentHash = currentUrlObj.hash;
    const newHash = newUrlObj.hash;
    return (
      currentHash !== newHash && currentUrlObj.href.replace(currentHash, '') === newUrlObj.href.replace(newHash, '')
    )
  }
  return false
}

const loaderElementId = 'zcHAgcx'

export const PageLoaderAnimationProvider = memo(function PageLoaderAnimationProvider_(props: { children?: React.ReactNode }): JSX.Element {

  const
    ref =
      useRef<{
        counter: 0 | 1,
        loaderEl: HTMLDivElement | null,
      }>({
        counter: 0,
        loaderEl: null,
      }),
		
    pathname =
      usePathname(),
		
    searchParams =
      useSearchParams()

  useEffect(() => {
    function onClickDocument(event: MouseEvent) {
      try {
        const target = event.target as HTMLElement
        const anchor = findClosestAnchor(target)
	
        if(anchor) {
          const
            currentUrl =
              window.location.href,
	
            targetUrl =
              anchor.href,
						
            isBlankTarget =
              anchor.target === '_blank'
					
          if(currentUrl === targetUrl || isAnchorOfCurrentUrl(currentUrl, targetUrl) || isBlankTarget) {
	    // i don't know. I just want to make sure to stop the animation. Even the animation is not started
	    // I just copied this from the nextjs-toploader
            stop()
          } else {
	    // When client is requesting the new page by clicking the anchor
	    // You can do your other logics here
            start()
          }
        }
      } catch(_err) {
        console.log('Error: ', _err)
        stop()
      }
    }

    // i don't know why. i don't even trust to my own code.
    if(ref.current.counter === 0) {
      ref.current.counter = 1
      document.addEventListener('click', onClickDocument)
    }

    return () => {
      document.removeEventListener('click', onClickDocument)
    }
  }, [])

  useEffect(() => {
    stop()
  }, [
    pathname,
    searchParams,
  ])

  function start() {
    if(!ref.current.loaderEl) {
      ref.current.loaderEl = document.getElementById(loaderElementId) as HTMLDivElement
    }

    ref.current.loaderEl.style.display = 'flex'
    ref.current.loaderEl.style.animationIterationCount = 'infinite'
  }

  function stop() {
    if(!ref.current.loaderEl) {
      ref.current.loaderEl = document.getElementById(loaderElementId) as HTMLDivElement
    }

    ref.current.loaderEl.style.display = 'none'
    ref.current.loaderEl.style.animationIterationCount = '0'
  }

  return (
    <PageLoaderAnimationContext.Provider
      value={{
        start,
        stop,
      }}
    >
      <LoaderLego.LineSlide
        id={ loaderElementId }
        className={ `
          fixed
          top-0 left-0
          w-full
          z-[100]
        ` }
      />

      { props.children }
    </PageLoaderAnimationContext.Provider>
  )

})
import * as StringHelper from 'your/helpers/string'

import SCSS from './style.module.scss'

export interface LineSlidePropsInterface {
  className?: string,
  style?: React.CSSProperties,
  
  lineClassName?: string,
  lineStyle?: React.CSSProperties,
}

export function LineSlide(props: LineSlidePropsInterface): JSX.Element {
  return (
    <div className={ StringHelper.minifyWhitespaces(`w-full overflow-hidden ${props.className || ''}`) } style={ props.style }>
      <div
        className={ StringHelper.minifyWhitespaces(`
          h-[3px]
          bg-color-token-in-tailwind
          ${SCSS.slideMoveAnimation}

          ${props.lineClassName || ''}
        `) }
        style={ props.lineStyle }
      />
    </div>
  )
}

The style.module.scss, personally weird to write this only for this simple animation purpose in tailwind.config.js

.slideMoveAnimation {
  animation-name: moveRight;
  animation-duration: 1s;
  animation-timing-function: ease;
  animation-iteration-count: infinite;
}

@keyframes moveRight {
  from {
    transform: translateX(-100%);
  }
  to {
    transform: translateX(100%);
  }
}

And, in the layout.tsx

import {
  PageLoaderAnimationProvider,
} from '@/path/to/the-page-loader-animation-context-i-wrote-above'

export default function Layout(props: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <PageLoaderAnimationProvider>
          { props.children }
        </PageLoaderAnimationProvider>
      </body>
    </html>
  )
}

To programatically start the top loader

import {
  useContext,
} from 'react'

import {
  useRouter,
} from 'next/navigation'

import PageLoaderAnimationContext from '@/path/to/the-page-loader-animation-context-i-wrote-above'

export default function YourComponent(): JSX.Element {
  const
    router =
      useRouter(),
 
    pageLoaderAnimationContext =
      useContext(PageLoaderAnimationContext)
    
  const clickThisMf: React.MouseEventHandler<HTMLDivElement> = () => {
    pageLoaderAnimationContext.start()
    router.push('/some/pathname')
  }
  
  return (
    <div onClick={ clickThisMf }>
      <p>some text to click</p>
    </div>
  )
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment