Skip to content

Commit f80c6f5

Browse files
feat: add scroll-to-top button with gradient progress indicator (#194)
* feat: add scroll-to-top button with gradient progress indicator - Add ScrollToTop component with orange-to-yellow gradient - Implement smooth scroll progress tracking with circular indicator - Add hover effects with scale animation (duration: 500ms ease-out) - Include dark mode support with appropriate color schemes - Integrate component into main Layout for site-wide availability - Add accessibility features with proper aria-label - Button appears after scrolling 300px with smooth fade-in/out Signed-off-by: Soumyabrata Mukherjee <[email protected]> * perf: optimize ScrollToTop component performance and code clarity - Add throttling to scroll event listener (16ms delay for ~60fps) - Extract magic number 15.92 as CIRCLE_RADIUS constant - Extract scroll threshold 300 as SCROLL_THRESHOLD constant - Add THROTTLE_DELAY constant for better maintainability - Implement custom throttle function to prevent excessive scroll calculations - Update SVG circles to use CIRCLE_RADIUS constant - Improve code readability and performance on lower-end devices Signed-off-by: Soumyabrata Mukherjee <[email protected]> --------- Signed-off-by: Soumyabrata Mukherjee <[email protected]>
1 parent 64983c3 commit f80c6f5

File tree

2 files changed

+161
-22
lines changed

2 files changed

+161
-22
lines changed

components/ScrollToTop.tsx

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { useState, useEffect, useCallback } from "react";
2+
3+
// Constants
4+
const CIRCLE_RADIUS = 15.92;
5+
const SCROLL_THRESHOLD = 300;
6+
const THROTTLE_DELAY = 16; // ~60fps
7+
8+
const ScrollToTop = () => {
9+
const [isVisible, setIsVisible] = useState(false);
10+
const [scrollProgress, setScrollProgress] = useState(0);
11+
12+
// Throttle function to limit scroll event frequency
13+
const throttle = useCallback((func: Function, delay: number) => {
14+
let timeoutId: NodeJS.Timeout | null = null;
15+
let lastExecTime = 0;
16+
17+
return (...args: any[]) => {
18+
const currentTime = Date.now();
19+
20+
if (currentTime - lastExecTime > delay) {
21+
func(...args);
22+
lastExecTime = currentTime;
23+
} else {
24+
if (timeoutId) clearTimeout(timeoutId);
25+
timeoutId = setTimeout(() => {
26+
func(...args);
27+
lastExecTime = Date.now();
28+
}, delay - (currentTime - lastExecTime));
29+
}
30+
};
31+
}, []);
32+
33+
useEffect(() => {
34+
const toggleVisibility = () => {
35+
if (window.pageYOffset > SCROLL_THRESHOLD) {
36+
setIsVisible(true);
37+
} else {
38+
setIsVisible(false);
39+
}
40+
};
41+
42+
const updateScrollProgress = () => {
43+
const scrollTop = window.pageYOffset;
44+
const docHeight =
45+
document.documentElement.scrollHeight - window.innerHeight;
46+
const scrollPercent = (scrollTop / docHeight) * 100;
47+
setScrollProgress(scrollPercent);
48+
};
49+
50+
const handleScroll = () => {
51+
toggleVisibility();
52+
updateScrollProgress();
53+
};
54+
55+
// Throttled scroll handler
56+
const throttledScrollHandler = throttle(handleScroll, THROTTLE_DELAY);
57+
58+
window.addEventListener("scroll", throttledScrollHandler);
59+
return () => window.removeEventListener("scroll", throttledScrollHandler);
60+
}, [throttle]);
61+
62+
const scrollToTop = () => {
63+
window.scrollTo({
64+
top: 0,
65+
behavior: "smooth",
66+
});
67+
};
68+
69+
const circumference = 2 * Math.PI * CIRCLE_RADIUS;
70+
const strokeDashoffset =
71+
circumference - (scrollProgress / 100) * circumference;
72+
73+
return (
74+
<button
75+
className={`fixed right-5 bottom-5 w-12 h-12 rounded-full cursor-pointer z-50 flex items-center justify-center transition-all duration-500 ease-out ${
76+
isVisible
77+
? "opacity-100 scale-100 visible"
78+
: "opacity-0 scale-0 invisible"
79+
} hover:opacity-80 hover:scale-110`}
80+
onClick={scrollToTop}
81+
aria-label="Scroll to top"
82+
>
83+
<svg viewBox="0 0 34 34" className="w-full h-full transform -rotate-90">
84+
<defs>
85+
<linearGradient
86+
id="progressGradient"
87+
x1="0%"
88+
y1="0%"
89+
x2="100%"
90+
y2="0%"
91+
>
92+
<stop offset="0%" stopColor="#f97316" />
93+
<stop offset="100%" stopColor="#eab308" />
94+
</linearGradient>
95+
</defs>
96+
<circle
97+
className="fill-white stroke-gray-300 opacity-90 dark:fill-gray-800 dark:stroke-gray-600"
98+
cx="17"
99+
cy="17"
100+
r={CIRCLE_RADIUS}
101+
strokeWidth="1.5"
102+
/>
103+
<circle
104+
className="fill-none"
105+
cx="17"
106+
cy="17"
107+
r={CIRCLE_RADIUS}
108+
strokeWidth="1.5"
109+
strokeLinecap="round"
110+
stroke="url(#progressGradient)"
111+
strokeDasharray={circumference}
112+
strokeDashoffset={strokeDashoffset}
113+
style={{ transition: "stroke-dashoffset 50ms ease-out" }}
114+
/>
115+
<path
116+
className="fill-none stroke-gray-800 dark:stroke-white"
117+
d="M15.07,21.06,19.16,17l-4.09-4.06"
118+
strokeWidth="1.5"
119+
/>
120+
</svg>
121+
</button>
122+
);
123+
};
124+
125+
export default ScrollToTop;

components/layout.tsx

Lines changed: 36 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
11
import { Post } from "../types/post";
2-
import Alert from './alert'
3-
import Footer from './footer'
4-
import Meta from './meta'
5-
import Script from 'next/script';
2+
import Alert from "./alert";
3+
import Footer from "./footer";
4+
import Meta from "./meta";
5+
import Script from "next/script";
66
import { motion } from "framer-motion";
7-
export default function Layout({ preview, children, featuredImage, Title, Description }:{
7+
import ScrollToTop from "./ScrollToTop";
8+
export default function Layout({
9+
preview,
10+
children,
11+
featuredImage,
12+
Title,
13+
Description,
14+
}: {
815
preview: any;
916
Description: any;
1017
featuredImage: Post["featuredImage"]["node"]["sourceUrl"];
@@ -13,7 +20,11 @@ export default function Layout({ preview, children, featuredImage, Title, Descri
1320
}) {
1421
return (
1522
<>
16-
<Meta featuredImage={featuredImage} Title={Title} Description={Description} />
23+
<Meta
24+
featuredImage={featuredImage}
25+
Title={Title}
26+
Description={Description}
27+
/>
1728
<motion.div
1829
initial={{ opacity: 0 }}
1930
animate={{ opacity: 1 }}
@@ -29,8 +40,12 @@ export default function Layout({ preview, children, featuredImage, Title, Descri
2940
<main>{children}</main>
3041
</motion.div>
3142
<Footer />
43+
<ScrollToTop />
3244

33-
<Script async src="https://www.googletagmanager.com/gtag/js?id=G-GYS09X6KHS" />
45+
<Script
46+
async
47+
src="https://www.googletagmanager.com/gtag/js?id=G-GYS09X6KHS"
48+
/>
3449
<Script
3550
id="google-ga"
3651
type="text/javascript"
@@ -44,8 +59,6 @@ export default function Layout({ preview, children, featuredImage, Title, Descri
4459
}}
4560
/>
4661

47-
48-
4962
<Script
5063
id="msclarity"
5164
type="text/javascript"
@@ -62,16 +75,18 @@ export default function Layout({ preview, children, featuredImage, Title, Descri
6275

6376
{/* publisher Script */}
6477

65-
<Script async type="application/javascript"
66-
id="swg-basic"
67-
src="https://news.google.com/swg/js/v1/swg-basic.js">
68-
</Script>
78+
<Script
79+
async
80+
type="application/javascript"
81+
id="swg-basic"
82+
src="https://news.google.com/swg/js/v1/swg-basic.js"
83+
></Script>
6984

70-
<Script
71-
id="publisher"
72-
strategy="afterInteractive"
73-
dangerouslySetInnerHTML={{
74-
__html: `
85+
<Script
86+
id="publisher"
87+
strategy="afterInteractive"
88+
dangerouslySetInnerHTML={{
89+
__html: `
7590
(self.SWG_BASIC = self.SWG_BASIC || []).push( basicSubscriptions => {
7691
basicSubscriptions.init({
7792
type: "NewsArticle",
@@ -81,11 +96,10 @@ export default function Layout({ preview, children, featuredImage, Title, Descri
8196
});
8297
});
8398
`,
84-
}}
85-
/>
86-
99+
}}
100+
/>
87101

88-
{/* Apollo Tracking Script */}
102+
{/* Apollo Tracking Script */}
89103
<Script
90104
id="apollo-tracker"
91105
type="text/javascript"

0 commit comments

Comments
 (0)