Building a Smooth, Springy Reading Progress Bar in Next.js
How to create a delightful reading progress indicator with custom spring physics, zero dependencies, and top-tier performance.
Reading progress bars are a popular way to give users visual feedback on long-form content. But a simple linear bar can often feel rigid and "robotic."
In this post, I'll walk you through how I built a reading progress bar for this very blog, added a delightful "spring" animation to it, and then optimized it to use zero external dependencies—dropping a 30kB library in favor of a 1kB hook.
The Goal
We want a thin progress bar fixed to the top of the viewport that tracks how far down the user has scrolled. But instead of the bar jumping instantly to the scroll position (1:1 mapping), we want it to follow the scroll action with a slight delay and momentum—like a rubber band or a spring. This makes the UI feel more "alive" and organic.
Step 1: The Naive Implementation
At its core, a reading progress bar just requires comparing window.scrollY to the document's total height.
A basic React component might look like this:
export function ReadingProgressBar() {
const [progress, setProgress] = useState(0);
useEffect(() => {
const updateProgress = () => {
// Calculate % of page scrolled
const currentScroll = window.scrollY;
const scrollHeight = document.documentElement.scrollHeight - window.innerHeight;
if (scrollHeight) {
setProgress((currentScroll / scrollHeight) * 100);
}
};
window.addEventListener("scroll", updateProgress);
return () => window.removeEventListener("scroll", updateProgress);
}, []);
return <div style={{ width: `${progress}%` }} className="h-1 bg-blue-500 fixed top-0" />;
}
This works, but it's linear. As you scroll, the bar moves instantly. It feels a bit mechanical.
Step 2: Adding "Juice" with Spring Physics
To make it feel better, we can apply spring physics. A spring system tries to pull a value towards a target (the scroll position), but it has mass and momentum, so it doesn't get there instantly.
Initially, I used framer-motion for this. It's an incredible library for animation.
import { motion, useScroll, useSpring } from "framer-motion";
export function ReadingProgressBar() {
const { scrollYProgress } = useScroll();
const scaleX = useSpring(scrollYProgress, {
stiffness: 100,
damping: 30,
restDelta: 0.001
});
return (
<motion.div
className="fixed top-0 inset-x-0 h-1 bg-blue-500 origin-left"
style={{ scaleX }}
/>
);
}
This felt amazing. The bar would "catch up" to your scroll, and if you scrolled fast and stopped, it would overshoot slightly before settling.
But there was a problem.
Step 3: The Optimization (Dropping 30kB)
framer-motion is powerful, but it's relatively heavy (~30kB minified). Importing it just for a single progress bar at the top of the page seemed excessive.
Why ship 30kB of JavaScript when we can do the math ourselves?
I replaced framer-motion with a custom useSpring hook. The physics logic is surprisingly simple:
Acceleration = (Target - CurrentValue) * Stiffness - Velocity * Damping
By running this physics loop on every frame using requestAnimationFrame, we get the exact same buttery smooth effect with zero dependencies.
The Custom Hook
Here is the lightweight hook I wrote to replace the library:
function useSpring(targetValue: number) {
const [value, setValue] = useState(targetValue);
const state = useRef({ value: targetValue, velocity: 0, target: targetValue });
useEffect(() => {
state.current.target = targetValue;
}, [targetValue]);
useEffect(() => {
// ... animation loop implementing the physics formula below ...
// acceleration = displacement * stiffness - velocity * damping
// ... using requestAnimationFrame ...
}, [targetValue]);
return value;
}
You can view the full source code on my GitHub.
The Result
The final result was a silky smooth progress bar. However, I've since removed it from this site.
Update: Why I Removed It
While the engineering was fun and the result was performant, I realized that for my minimal blog design, a moving bar at the top was distracting.
It drew the eye away from the content, which is the most important thing. Sometimes, "unwanted attention" to UI elements comes at the cost of readability.
So, while the code below works perfectly and is highly optimized, I decided to prioritize a distraction-free reading experience over a cool UI feature.
The Result (When it was live)
It was:
- Silky smooth (60/120fps)
- Responsive (spring physics)
- Lightweight (~1kB gzipped)
Sometimes, the best library is no library at all.