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>
)
}