Quick note: I’m Kayla. I make sites, but I also just love little UI bits that feel kind. A tiny bar that fills as you read? Feels kind.
Below is my take after using a CSS scroll indicator on three real pages. I’ll share what worked, what bugged me, and the code I leaned on.
What I’ll cover (real quick)
- What a scroll indicator is and why I used it
- Three builds I tried (CSS-only, small JS, container scroll)
- Real examples from my blog, docs, and a shop page
- Good stuff, rough stuff, and a few tips
So… what is a scroll indicator?
It’s a slim bar that fills while you scroll a page. It shows “how far am I?” at a glance. It helps folks pace themselves. It calms that little “how much is left?” itch. For a deep dive into the design philosophy and alternate code patterns, CSS-Tricks has an excellent scroll-indicator write-up right here.
I like it most on long posts and guides. Less so on short pages. If the page is tiny, it can feel silly.
You know what? People notice it, but in a good way—like a gentle nod, not a shout.
If you're hunting for other lightweight UI snippets, there’s a treasure trove of pure-CSS patterns over at CSS Menu Tools.
If you’d like the blow-by-blow diary of my very first experiment (with extra demos and pitfalls), I put together a full case study on CSS Menu Tools that you can read right here.
Build #1: Modern CSS (works great, unless you’re on Firefox)
I first tried a CSS-only version. It felt smooth and light. No jank on scroll. The trick uses scroll-linked animations.
Here’s a simple cut I used on my blog (Chrome, Edge, and Safari were fine; Firefox didn’t support it yet when I shipped):
<div class="progress"></div>
.progress {
position: fixed;
top: 0; left: 0;
height: 4px;
width: 100%;
background: linear-gradient(to right, #4f46e5 0 0) left/0% 100% no-repeat, #e5e7eb;
z-index: 9999;
}
/* Scroll-linked animation */
@keyframes fill {
from { background-size: 0% 100%; }
to { background-size: 100% 100%; }
}
/* Modern bit: ties the animation to page scroll */
.progress {
animation: fill linear both;
animation-timeline: scroll(root block);
animation-range: 0% 100%;
}
Why I liked it:
- No JavaScript.
- Super smooth on phones.
- Easy to theme with a CSS variable.
What tripped me up:
- Firefox support lagged, so I needed a fallback.
- Nested scrollers got weird. Root vs. container matters.
Build #2: Tiny JS (works everywhere, still fast)
For full support, I shipped a small JS version. It updates a scale on a bar. It’s still quick, and it’s clear.
<div class="progress"></div>
.progress {
position: fixed;
top: 0; left: 0;
height: 4px;
width: 100%;
background: #e5e7eb;
transform-origin: left center;
overflow: hidden;
}
.progress::before {
content: "";
display: block;
height: 100%;
width: 100%;
background: #4f46e5;
transform: scaleX(0);
transform-origin: left center;
}
const bar = document.querySelector('.progress');
function setProgress(pct) {
bar.style.setProperty('--pct', pct);
bar.style.setProperty('transform', 'none'); // keeps layout stable
bar.firstElementChild?.style?.setProperty('transform', `scaleX(${pct})`);
}
function update() {
const el = document.scrollingElement || document.documentElement;
const max = el.scrollHeight - el.clientHeight;
const pct = max > 0 ? el.scrollTop / max : 0;
setProgress(pct);
}
let ticking = false;
window.addEventListener('scroll', () => {
if (!ticking) {
requestAnimationFrame(() => {
update();
ticking = false;
});
ticking = true;
}
}, { passive: true });
window.addEventListener('resize', update, { passive: true });
update();
If you’d prefer a straightforward tutorial, the vanilla JavaScript method is also covered by W3Schools in their step-by-step guide here.
Notes from the field:
- requestAnimationFrame kept it smooth.
- Passive listeners helped on mobile.
- Using scaleX kept layout stable and avoided weird jumps.
If you’re looking to drop a similar bar into a hosted platform like Squarespace, I chronicled the exact custom-CSS quirks (spoiler: it’s easier than I thought) in this write-up.
Build #3: A scrollable container (docs page gotcha)
My docs page had a fixed sidebar and a main content area that scrolled by itself. The window didn’t scroll—only the content area did. So I needed to read that container’s scrollTop.
<aside class="sidebar">...</aside>
<main class="content" id="reader">
<div class="progress"></div>
<!-- long docs here -->
</main>
const area = document.getElementById('reader');
const bar = area.querySelector('.progress');
function updateContainer() {
const max = area.scrollHeight - area.clientHeight;
const pct = max > 0 ? area.scrollTop / max : 0;
bar.firstElementChild?.style?.setProperty('transform', `scaleX(${pct})`);
}
area.addEventListener('scroll', () => {
requestAnimationFrame(updateContainer);
}, { passive: true });
new ResizeObserver(updateContainer).observe(area);
updateContainer();
This fixed the “bar doesn’t move” bug. ResizeObserver helped because content height changed as code blocks loaded.
Real pages I shipped it on
-
My long blog post (about 2,400 words)
- I used the CSS-only version with a JS fallback.
- Color matched my brand purple.
- I kept it 4px tall, right under a sticky header.
- Tiny note: After I shipped, average read time rose by about 18% over two weeks. Not a lab test, but I felt good about it. One reader even emailed, “That progress bar kept me going.”
-
A docs page at work
- Needed the container scroll fix above.
- I set the bar to a calm blue and bumped contrast for accessibility.
- I turned it off on short pages (less than one screen tall).
-
A product story page for a small shop
- The page had huge images with lazy loading.
- Height kept changing, so the percent jumped.
- I added ResizeObserver and a 100ms rAF buffer. It smoothed out the wiggles.
Stuff I liked
- It gives calm feedback. No noise. No badge. Just a line.
- It guides pace on long content. People read and relax.
- CSS-only feels very clean when it works.
- The JS version is still tiny and sure.
Speaking of polished consumer products that rely on subtle feedback loops, dating apps are a gold mine of micro-interaction ideas. Zoosk, for example, sprinkles progress cues throughout onboarding and profile completion to keep new users moving. A detailed Zoosk review walks through those UX choices, its standout features, and who the app is best suited for—handy if you’re curious how these design patterns translate to real engagement numbers.
Stuff that bugged me
- Browser support for the CSS timeline was spotty, so I had to plan a fallback.
- Nested scroll areas will trick you at least once.
- Color contrast matters a lot. Light gray on white is useless.
- If you don’t reserve space, sticky headers can hide it. Or overlap it. I’ve done both. Twice.
Small accessibility notes
- I tested 3–4px height. Easy to see, not loud.
- High contrast against the page background.
- I respect prefers-reduced-motion. If it’s on, I show a static bar that jumps in larger steps (25%, 50%, 75%), or I hide it on short pages.
@media (prefers-reduced-motion: reduce) {
.progress::before { transition: none; }
}
If I use live text for screen readers, I only update in big chunks. No spam.
Tips that saved me
- Keep it thin: 3–4px is plenty.
- Anchor under the header; test on small phones.
- Turn it off on short pages.
- Use requestAnimationFrame for scroll work.
- For container scroll, listen to