Home/Components/Scroll to Top Button React Tailwind CSS Progress Indicator
Code · Live Preview
Componenttsx
// 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>
  );
}
Detecting libraries...

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

terminal
npm
npm install tailwindcss @tailwindcss/vite framer-motion lucide-react

Dependencies

framer-motionlucide-reacttailwindcss
Added Jun 18, 2026

Discussion

Feedback, implementation tips, and usage notes.

0 threads

Comments

Sign in to join the conversation