ScrollOut Scroll Progress Bar — CSS Custom Property Snippet

ScrollOut Scroll Progress Bar · Misc · Plain HTML, CSS & JS · Live preview

What's included

Features

Zero manual scroll math
No scrollY / scrollHeight arithmetic anywhere in this snippet's JS.
Real documented cssProps option
scrollPercentY is an actual ScrollOut v2 variable, converted to --scroll-percent-y in kebab case.
CSS-only bar width
The progress fill reads the variable directly via calc(), with no inline style writes from JS.
Opt-in variable selection
Passing an object to cssProps enables only the named variables instead of the full set.
Fallback default value
A :root default keeps calc() valid before ScrollOut's first update.
Text mirror via getComputedStyle
The one legitimate case where JS must read the variable back, since CSS can't render it as text.
Smooth continuous updates
The bar tracks scroll position continuously rather than jumping in discrete steps.
Inheritable custom property
The variable is set once on the document root and consumed anywhere via normal CSS inheritance.

About this UI Snippet

ScrollOut Scroll Progress Bar — Reading a Real Documented cssProps Variable

Screenshot of the ScrollOut Scroll Progress Bar snippet rendered live

A scroll progress bar is usually built by listening to scroll, dividing window.scrollY by document.body.scrollHeight - window.innerHeight, and writing the result to a style property on every event. ScrollOut's `cssProps` option removes that math entirely by computing it internally and exposing the result as a CSS custom property you read with calc().

The real, documented option: cssProps

js ScrollOut({ cssProps: { scrollPercentY: true } });

cssProps is opt-in: passing true enables every variable ScrollOut can produce, and passing an object (as here) enables only the ones you name, keyed in camelCase. ScrollOut converts each key to a kebab-case CSS custom property when writing it to the DOM, so scrollPercentY: true becomes --scroll-percent-y on the tracked scrolling element — the document root by default, since no scrollingElement override was passed. It is a real ScrollOut v2 option name, not an invented one; the library's other scroll-position variables follow the same camelCase-to-kebab convention (--scroll-percent-x, --scroll-dir-y, and so on for the per-target visibility props like --visible-y).

Why the bar itself has no JavaScript

css .spb-bar-fill{width:calc(var(--scroll-percent-y,0) * 100%)}

--scroll-percent-y is already a 0-to-1 float, so multiplying by 100% inside calc() gives a percentage the width property can consume directly. Because ScrollOut updates this variable on the *document element* rather than on the bar itself, and CSS custom properties inherit down through the DOM tree, the rule above just works without JS ever touching .spb-bar-fill's style — the browser's own style-recalculation pipeline handles the redraw every time the variable's computed value changes, which is typically cheaper than a scroll-driven JS style write because it skips the extra JS-to-style round trip on every tick.

Why the badge still needs one JS read

CSS has no way to render a custom property's numeric value as visible text — content: var(--scroll-percent-y) doesn't work the way you'd hope, because content treats it as an opaque string, not a computable percentage. So the small corner badge is the one piece of this snippet that reads the variable back out with getComputedStyle(root).getPropertyValue('--scroll-percent-y'), parses it as a float, and writes formatted text — a narrow, deliberate exception to the "let CSS own it" rule, done in a requestAnimationFrame loop so the number stays in sync with the bar without a redundant scroll listener of its own.

:root{ --scroll-percent-y: 0 } as a fallback

The variable is defined with a default value at the top of the stylesheet so the calc() in .spb-bar-fill has something valid to read before ScrollOut's first scroll sample runs — without it, an unset custom property makes the whole calc() expression invalid and the bar would render at its browser-default width instead of 0 on first paint.

Build with AI

Build, Understand, Optimize, and Extend It With AI

This snippet is worth using to have an AI verify a specific, checkable claim: that --scroll-percent-y is a real ScrollOut cssProps variable and not an invented option name. Paste it into an assistant like Claude and ask it to explain exactly how the cssProps option's camelCase keys become kebab-case CSS custom properties, and why passing an object instead of true is the more deliberate choice. Then ask why the progress bar's width needs no JavaScript at all while the percentage badge does — the answer should turn on CSS inheritance versus CSS's inability to render a variable as text content. To extend it: ask for a version that also exposes --scroll-dir-y to fade the bar's color between two hues depending on scroll direction, a version scoped to a scrollable container instead of the whole page via the scrollingElement option, or a version that pairs this page-wide bar with the per-section threshold tracking from the ScrollOut Progress Nav Dots snippet in the same library.

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:

text
Build a fixed scroll progress bar using ScrollOut (v2, from a CDN) in plain HTML, CSS, and JavaScript.

Requirements:
- A thin bar fixed to the very top of the viewport whose fill width represents how far the user has scrolled down the page, plus a small floating badge in a corner showing the same value as a "NN%" text label.
- Initialize with ScrollOut({ cssProps: { scrollPercentY: true } }) — use this exact, real, documented ScrollOut v2 option (cssProps, opt-in per-variable via an object of camelCase keys). Do not invent an option name; scrollPercentY is the real one that becomes the --scroll-percent-y CSS custom property on the document root.
- The bar's fill width must be driven ENTIRELY by CSS: width: calc(var(--scroll-percent-y, 0) * 100%), with zero JavaScript setting any style property on the bar element. Define a :root { --scroll-percent-y: 0 } fallback so the calc() is valid before ScrollOut's first update.
- The only JavaScript beyond the ScrollOut() call should be for the percentage badge: read the current value with getComputedStyle(document.documentElement).getPropertyValue('--scroll-percent-y'), parse it, and write it as rounded percentage text in a requestAnimationFrame loop — explain in a comment that this JS read is required only because CSS cannot render a custom property's value as visible text.
- Build a long single page (at least 5-6 sections of body copy) so there's enough scroll distance to demonstrate the bar filling from 0% to 100%.
- Style it as a clean dark-themed page with a gradient-colored bar fill.

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.

Source Code

Requires
<div class="spb-bar-track"><div class="spb-bar-fill" id="spbFill"></div></div>
<div class="spb-badge" id="spbBadge">0%</div>
<div class="spb-page">
  <div class="spb-head">
    <span class="spb-tag">ScrollOut · CSS custom property</span>
    <h2>Scroll progress, no manual math</h2>
    <p>The bar's width is driven entirely by <code>--scroll-percent-y</code>, a CSS variable ScrollOut writes to the document element on every scroll tick.</p>
  </div>
  <article class="spb-copy">
    <h3>Section one</h3>
    <p>ScrollOut's cssProps option decorates the scrolling element with a set of custom properties describing overall scroll position. --scroll-percent-y is one of them: a 0-to-1 float representing how far down the page has been scrolled.</p>
    <h3>Section two</h3>
    <p>The bar itself never runs any JS math for its width. A single CSS rule reads the variable directly: width: calc(var(--scroll-percent-y, 0) * 100%).</p>
    <h3>Section three</h3>
    <p>A small badge in the corner mirrors the same percentage as text, read from the variable in JS only for that one display purpose.</p>
    <h3>Section four</h3>
    <p>Keep scrolling — the bar and badge track continuously, not in discrete steps, because the underlying scroll listener fires on every frame ScrollOut samples.</p>
    <h3>Section five</h3>
    <p>Near the bottom now. The bar should read close to 100% by the time this paragraph is in view.</p>
    <h3>End</h3>
    <p>That's the whole mechanism — one ScrollOut call, one CSS variable, one calc().</p>
  </article>
</div>

Step by step

How to Use

  1. 1
    Add the ScrollOut CDN scriptOne script tag — no companion stylesheet is needed for this snippet.
  2. 2
    Enable cssProps.scrollPercentYPass ScrollOut({ cssProps: { scrollPercentY: true } }) to opt into just that one variable.
  3. 3
    Read the variable in CSS with calc()width: calc(var(--scroll-percent-y, 0) * 100%) on the bar — no JS style writes.
  4. 4
    Define a fallback default:root { --scroll-percent-y: 0 } keeps the calc() valid before the first scroll sample.
  5. 5
    Optionally mirror the value as textgetComputedStyle(root).getPropertyValue() is the only way to print the number, since CSS content can't render it.
  6. 6
    Scroll the pageThe bar fills smoothly from 0% to 100% as scroll position moves from top to bottom.

Real-world uses

Common Use Cases

Blog and article pages
A classic top progress bar showing how far a reader has gotten through a long post.
Teaching CSS custom properties
A concrete example of a JS library exposing state through CSS variables instead of style writes.
Documentation sites
Pair with a table of contents to show overall reading progress separately from section tracking.
Landing pages with long scroll
A subtle progress cue on a scrollytelling marketing page.

Got questions?

Frequently Asked Questions

Yes. It comes from ScrollOut's cssProps option, which decorates the tracked scrolling element with CSS custom properties describing scroll state. The JS key is camelCase (scrollPercentY) and ScrollOut writes it to the DOM as kebab-case (--scroll-percent-y), following the same conversion used for its other variables.

Passing true enables every CSS variable ScrollOut can produce, including several this page doesn't use. Passing { scrollPercentY: true } opts into only that one, which keeps unnecessary custom properties off the document element.

ScrollOut writes --scroll-percent-y on the document root on every scroll sample, and CSS custom properties inherit down the DOM tree. The bar's CSS rule, width: calc(var(--scroll-percent-y, 0) * 100%), reads that inherited value directly, so the browser's style engine handles the redraw without any JS style write.

CSS has no way to render a custom property's value as visible text — content: var(...) treats it as an opaque token, not a computed percentage. Reading it back with getComputedStyle() and writing formatted text is the only way to display the number, so the badge is a deliberate, narrow exception.

It gives the variable a valid default before ScrollOut writes its first real value. Without a default, an unset custom property makes the calc() expression that depends on it invalid, and the bar would fall back to its default CSS width instead of starting at 0%.

Yes, with one change — pass scrollingElement pointing at that container to ScrollOut's config, since --scroll-percent-y is written to whichever element ScrollOut is told is the scrolling element, which defaults to the document.