// src/components/ui/ScrollToTopButton.tsx
"use client";
import { ArrowUp } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import {
motion,
AnimatePresence,
useMotionValue,
useSpring,
useTransform,
} from "framer-motion";
const RADIUS = 26;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
export default function ScrollToTopButton() {
const [visible, setVisible] = useState(false);
const progress = useMotionValue(0); // raw scroll progress, 0 -> 1
const smoothProgress = useSpring(progress, {
stiffness: 120,
damping: 20,
mass: 0.5,
});
const rafRef = useRef<number | null>(null);
useEffect(() => {
const updateProgress = () => {
const scrollTop = window.scrollY;
const docHeight =
document.documentElement.scrollHeight - window.innerHeight;
const pct = docHeight > 0 ? Math.min(scrollTop / docHeight, 1) : 0;
progress.set(pct);
setVisible(scrollTop > 100);
rafRef.current = null;
};
const onScroll = () => {
if (rafRef.current === null) {
rafRef.current = requestAnimationFrame(updateProgress);
}
};
window.addEventListener("scroll", onScroll, { passive: true });
updateProgress(); // initial
return () => {
window.removeEventListener("scroll", onScroll);
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, [progress]);
const dashOffset = useTransform(smoothProgress, [0, 1], [CIRCUMFERENCE, 0]);
const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: "smooth" });
};
return (
<AnimatePresence>
{visible && (
<motion.button
onClick={scrollToTop}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.2 }}
whileHover={{ scale: 1.08 }}
whileTap={{ scale: 0.92 }}
aria-label="Scroll to top"
className="fixed bottom-8 right-8 z-10 flex items-center justify-center w-14 h-14 rounded-full bg-black/70 backdrop-blur-sm cursor-pointer shadow-lg"
>
{/* Track for the "border" */}
<svg
className="absolute inset-0 w-full h-full -rotate-90"
viewBox="0 0 56 56"
>
<circle
cx="28"
cy="28"
r={RADIUS}
fill="none"
stroke="rgba(255,255,255,0.15)"
strokeWidth="2"
/>
{/* Progress ring — this is the scroll percentage */}
<motion.circle
cx="28"
cy="28"
r={RADIUS}
fill="none"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeDasharray={CIRCUMFERENCE}
style={{ strokeDashoffset: dashOffset }}
/>
</svg>
<ArrowUp className="w-5 h-5 text-white relative" strokeWidth={2.5} />
</motion.button>
)}
</AnimatePresence>
);
}Component details
Overview, install command, dependencies, and project links.
A scroll-to-top button built with React, Tailwind CSS, TypeScript, and Framer Motion - with an animated circular progress ring that fills as the user scrolls down the page. The button stays hidden until the user scrolls past 100px, then fades and scales in smoothly. Scroll progress is tracked using requestAnimationFrame for performance, then smoothed through a Framer Motion spring before being mapped to the SVG stroke-dashoffset - so the ring fills fluidly instead of jumping with every scroll event. Clicking the button triggers a native smooth scroll back to the top. Hover and tap states use spring-based scale transforms, and the whole button enters and exits with AnimatePresence. Fully typed in TypeScript with cleanup handled correctly on unmount to avoid memory leaks from the scroll listener and animation frame.
Install
npm install tailwindcss @tailwindcss/vite framer-motion lucide-reactDependencies
Discussion
Feedback, implementation tips, and usage notes.
Comments
Sign in to join the conversation
