Skip to content

Commit 4a8e749

Browse files
committed
Add TopLoader component for page loading indication
1 parent 4caaa60 commit 4a8e749

2 files changed

Lines changed: 120 additions & 0 deletions

File tree

app/layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Geist, Geist_Mono, Inter } from "next/font/google";
33
import "./globals.css";
44
import Navbar from "@/components/navbar";
55
import Footer from "@/components/footer";
6+
import TopLoader from "@/components/top-loader";
67
import { ThemeProvider } from "@/components/theme-provider";
78
import { siteConfig } from "@/config/site.config";
89
import { getSiteUrl } from "@/lib/site";
@@ -40,6 +41,7 @@ export default function RootLayout({
4041
className={`${geistSans.variable} ${geistMono.variable} antialiased ${inter.className}`}
4142
>
4243
<ThemeProvider>
44+
<TopLoader />
4345
<Navbar />
4446
{children}
4547
<Footer />

components/top-loader.tsx

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"use client"
2+
3+
import { useEffect, useRef, useState } from "react"
4+
import { usePathname } from "next/navigation"
5+
6+
export default function TopLoader() {
7+
const pathname = usePathname()
8+
const [loading, setLoading] = useState(false)
9+
const [width, setWidth] = useState(0)
10+
11+
const loadingRef = useRef(false)
12+
const trickle = useRef<ReturnType<typeof setInterval> | null>(null)
13+
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
14+
const firstRender = useRef(true)
15+
16+
function clearTimers() {
17+
if (trickle.current) {
18+
clearInterval(trickle.current)
19+
trickle.current = null
20+
}
21+
if (hideTimer.current) {
22+
clearTimeout(hideTimer.current)
23+
hideTimer.current = null
24+
}
25+
}
26+
27+
function start() {
28+
if (loadingRef.current) return
29+
loadingRef.current = true
30+
clearTimers()
31+
setLoading(true)
32+
setWidth(8)
33+
trickle.current = setInterval(() => {
34+
setWidth((w) => (w >= 90 ? w : w + Math.max(0.5, (90 - w) * 0.08)))
35+
}, 200)
36+
}
37+
38+
function done() {
39+
if (!loadingRef.current) return
40+
loadingRef.current = false
41+
clearTimers()
42+
setWidth(100)
43+
hideTimer.current = setTimeout(() => {
44+
setLoading(false)
45+
setWidth(0)
46+
}, 250)
47+
}
48+
49+
useEffect(() => {
50+
if (firstRender.current) {
51+
firstRender.current = false
52+
return
53+
}
54+
done()
55+
// eslint-disable-next-line react-hooks/exhaustive-deps
56+
}, [pathname])
57+
58+
useEffect(() => {
59+
function onClick(event: MouseEvent) {
60+
if (
61+
event.defaultPrevented ||
62+
event.button !== 0 ||
63+
event.metaKey ||
64+
event.ctrlKey ||
65+
event.shiftKey ||
66+
event.altKey
67+
) {
68+
return
69+
}
70+
const target = event.target as Element | null
71+
const anchor = target?.closest?.("a")
72+
if (!anchor) return
73+
if (anchor.target === "_blank" || anchor.hasAttribute("download")) return
74+
75+
const href = anchor.getAttribute("href")
76+
if (!href || href.startsWith("#")) return
77+
78+
let url: URL
79+
try {
80+
url = new URL(anchor.href, window.location.href)
81+
} catch {
82+
return
83+
}
84+
if (url.origin !== window.location.origin) return
85+
if (url.pathname === window.location.pathname) return
86+
87+
start()
88+
}
89+
90+
function onPopState() {
91+
start()
92+
}
93+
94+
document.addEventListener("click", onClick, true)
95+
window.addEventListener("popstate", onPopState)
96+
return () => {
97+
document.removeEventListener("click", onClick, true)
98+
window.removeEventListener("popstate", onPopState)
99+
clearTimers()
100+
}
101+
// eslint-disable-next-line react-hooks/exhaustive-deps
102+
}, [])
103+
104+
if (!loading) return null
105+
106+
return (
107+
<div
108+
className="pointer-events-none fixed inset-x-0 top-0 z-[9999] h-0.5"
109+
role="progressbar"
110+
aria-hidden="true"
111+
>
112+
<div
113+
className="h-full bg-gradient-to-r from-blue-400 to-blue-600 shadow-[0_0_10px_rgba(59,130,246,0.7)] transition-[width] duration-200 ease-out"
114+
style={{ width: `${width}%` }}
115+
/>
116+
</div>
117+
)
118+
}

0 commit comments

Comments
 (0)