You Might Also Like
Scroll Progress Footer — Free HTML CSS JS Back-to-Top Ring Snippet
Scroll Progress Footer · Footers · Plain HTML, CSS & JS · Live preview
What's included
Features
About this UI Snippet
Scroll Progress Footer — A Back-to-Top Button That Tells You Where You Are

A plain back-to-top button answers one question — "can I get to the top?" — but says nothing about how far down the page you actually are. This snippet pairs a standard footer with a fixed corner button whose ring fills to the exact percentage of the page you've scrolled, using the same SVG stroke-dash technique as the Download Button's progress ring, driven here by scroll position instead of a timer.
Two concentric circles, one static track and one animated arc
The button is two SVG <circle> elements sharing the same centre and radius: .spf-track is a faint, always-full ring that shows the button's total circumference, and .spf-prog sits on top with stroke-dasharray set to the circle's exact circumference (2πr, computed once in JS as CIRC) and stroke-dashoffset animated toward zero as the arc "draws in." Rotating the whole SVG -90deg makes the arc start filling from 12 o'clock rather than 3 o'clock, the same convention used in most native progress rings.
Scroll position, converted to a fraction, converted to an offset
On every scroll event, onScroll() computes max = page.scrollHeight - window.innerHeight — the total distance the page can actually scroll — and divides the current window.scrollY by it to get a 0–1 fraction. That fraction becomes strokeDashoffset = CIRC * (1 - pct): at the top of the page the offset equals the full circumference (an empty ring), and at the bottom it's zero (a complete ring). A short .1s linear CSS transition on the property smooths out the otherwise-jittery per-scroll-event updates without introducing any animation loop of its own.
A passive listener, and no per-frame polling
The scroll handler is registered with { passive: true }, telling the browser it will never call preventDefault(), which lets the browser scroll immediately without waiting to see if the handler blocks it — meaningful for scroll smoothness on any page, and free here since the handler only reads scroll position. There's deliberately no requestAnimationFrame loop: the ring only needs to update when a scroll event actually fires, not every frame regardless of whether anything changed.
Show, hide, and jump
The button stays hidden and non-interactive (opacity: 0; pointer-events: none) until window.scrollY passes 240px, at which point a .visible class fades and slides it in — so it never crowds the very top of the page where there's nothing to scroll back to. A click calls window.scrollTo({ top: 0, behavior: 'smooth' }), the native smooth-scroll API, rather than animating scroll position by hand.
Build with AI
Build, Understand, Optimize, and Extend It With AI
Paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain exactly why stroke-dashoffset is set to CIRC * (1 - pct) rather than CIRC * pct — the sign matters, and walking through why makes the stroke-dasharray/stroke-dashoffset mental model click for any future progress-ring work. It is also a good candidate for a performance discussion: ask whether the scroll handler should be throttled or debounced given that scroll events can fire dozens of times per second, and whether the current approach (updating a CSS custom property every event, relying on a short CSS transition to smooth it) is preferable to manually throttling with requestAnimationFrame. For extending it, ask for a version that also displays the scroll percentage as text inside the ring, or one that changes the ring's colour as it approaches 100% to signal "you've reached the end."
Prompt to recreate it
Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:
Build a website footer paired with a fixed "scroll progress" back-to-top button, in plain HTML, CSS, and vanilla JavaScript — no library.
Requirements:
- A standard footer (brand, a couple of link columns, copyright) at the bottom of a scrollable demo page with enough content to actually scroll.
- A circular button fixed to the bottom-right corner of the viewport, built from two concentric SVG circles: a faint static "track" circle and an animated "progress" circle using the stroke-dasharray/stroke-dashoffset technique, with the SVG rotated -90 degrees so the arc fills starting from 12 o'clock.
- On the window's scroll event (registered as passive), compute the scroll fraction as window.scrollY divided by (document scrollHeight minus viewport height), clamp it to 0-1, and set the progress circle's stroke-dashoffset to the circle's circumference times (1 minus that fraction), so the ring visually fills as the user scrolls down the page.
- Apply a short CSS transition (around 0.1s, linear) to stroke-dashoffset so per-scroll-event updates feel smooth rather than jittery, without using any JavaScript animation loop.
- Hide the button (zero opacity, no pointer events) until the user has scrolled past roughly 240px, then fade and slide it into view; clicking it should smooth-scroll the page to the top using the native window.scrollTo API with behavior: 'smooth'.
- Add a small up-arrow icon centered inside the ring, and add a prefers-reduced-motion media query that removes the CSS transition while keeping the ring's fill functional.Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.
Step by step
How to Use
- 1Paste HTML, CSS, and JSScroll the demo page — the ring in the bottom-right fills as you move down and appears after 240px of scroll.
- 2Click the ring to jump to topThe page smooth-scrolls to the top using the native scrollTo API, and the ring empties back out as you go.
- 3Adjust the appearance thresholdChange the 240 value in the JS to show the button sooner or later relative to scroll depth.
- 4Adjust the ring sizeChange the SVG width/height/radius together, and update CIRC = 2 * Math.PI * r to match the new radius exactly, or the ring will not draw correctly.
- 5Update the footer contentReplace the brand, link columns, and copyright in the footer with your own — the ring is a separate, independent fixed element.
- 6Export in your formatClick HTML, JSX, or Tailwind to download the version you need.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
The underlying SVG stroke-dasharray/stroke-dashoffset technique is identical, but the value driving it is different: the Download Button computes progress from an eased timer, while this ring reads real scroll position on every scroll event. There is no animation loop here at all — a CSS transition smooths the value updates instead.
It tells the browser the handler will never call preventDefault(), which lets the browser begin scrolling immediately instead of waiting to see whether the handler would block it. Since this handler only reads scroll position and never prevents default behaviour, passive mode is free correctness and free performance.
You must update the CIRC constant in the JS to 2 * Math.PI * r using the new radius, matching the SVG circle's r attribute. The stroke-dasharray and the dashoffset math both depend on that exact circumference — mismatching them will make the ring appear to fill only partially or overshoot.
No — it stays hidden (opacity: 0, non-interactive) until window.scrollY passes 240px, so it never competes for attention at the very top of the page where scrolling back to the top would be meaningless.
Yes — the scroll event fires for any scroll source, including keyboard (Page Down, spacebar, arrow keys), touch, and JavaScript-driven scrollTo calls elsewhere on the page, since the handler simply reads window.scrollY whenever the browser fires the event.
Click JSX, Vue, or Angular to download the converted component. Attach the scroll listener inside the mount lifecycle (useEffect in React, onMounted in Vue) and return/clean up the listener on unmount to avoid updating state after the component is gone.