---
title: useScrollProgress
description: How far one element has travelled up the viewport, 0 to 1, once per frame. Gated by an observer so an off-screen element costs nothing.
source: https://adamjurek.com/components/use-scroll-progress
---

## Home


A hook that reports how far one element has climbed the viewport, from 0 to 1,
once per frame - not how far the page has scrolled. Reach for it to drive
anything that should animate in as an element arrives, like a progress bar or
a reveal, gated by an IntersectionObserver so off-screen elements cost
nothing.

<!-- ::start:showcase demo="use-scroll-progress" height="380" -->
<!-- ::end:showcase -->

## Why it is built this way

An IntersectionObserver gates the scroll listener rather than driving the
value, because observers report crossings, not positions - they can't give a
smooth progress on their own, but they're the cheapest way to stop measuring
an element nobody can see. The callback is expected to write to a ref
directly rather than call `setState`, for the same reason `useScrollTurn`
does: sixty updates a second would re-render a subtree sixty times a second.

> [!NOTE] Install commands are not written here
> Anything in `packages/ui/registry.ts` gets its TanStack and shadcn commands
> attached automatically, so there is nothing to keep in sync.


## Install

<!-- ::start:tabs -->

### TanStack

```shell
tanstack add https://adamjurek.com/r/tanstack/use-scroll-progress.json
```

### shadcn

```shell
pnpm dlx shadcn@latest add https://adamjurek.com/r/shadcn/use-scroll-progress.json
```

### pnpm

```shell
pnpm add @sushindustries/ui @sushindustries/atoms
```

<!-- ::end:tabs -->

## What you get

| | |
| --- | --- |
| Version | 0.1.0 |
| Category | motion · Scroll effects |
| Files | `use-scroll-progress.ts` |
| Dependencies | None |
| Tags | scroll, hook, observer, no-deps |

> [!NOTE] No runtime dependencies
> It brings nothing with it beyond the stylesheet.

## Get Started


Install commands are on Home, attached from the registry - they are not written
here, because a second copy is a copy that goes stale. This tab starts after the
install worked.

## Use it

```tsx
import { useRef, useCallback } from "react";
import { useScrollProgress } from "@sushindustries/ui";

export function ProgressBar() {
	const stageRef = useRef<HTMLDivElement>(null);
	const barRef = useRef<HTMLDivElement>(null);

	const show = useCallback((progress: number) => {
		if (barRef.current) barRef.current.style.transform = `scaleX(${progress})`;
	}, []);

	useScrollProgress(stageRef, show);

	return (
		<div ref={stageRef} style={{ minHeight: "200vh" }}>
			<div ref={barRef} style={{ height: 6, background: "var(--fg)" }} />
		</div>
	);
}
```

## What you should see

Nothing until you scroll - the hook itself renders no markup. As the tall
`stageRef` element rises up the viewport, `show` fires roughly once per
frame, and the bar's `scaleX` climbs from 0 to 1 by the time the element's
top reaches 55% up the screen (the default `finishAt`). Scroll back down and
it reverses smoothly, because `progress` is a direct read of position, not a
one-shot trigger.

## If nothing happens

The most common cause is `stageRef` never attaching to anything tall enough
to scroll - a zero-height element never crosses the viewport, so `progress`
never leaves 0. The other is `whenVisible` doing exactly what it is meant to:
while the element is off screen the IntersectionObserver is not gating
`true`, so the scroll listener never runs and `onProgress` is never called
until it comes into view.


## Guides


## `finishAt` is measured from the bottom of the screen

It is a fraction of viewport height, not of the element's own height. `0.55`
means progress reaches 1 once the element's top has climbed to 55% up the
screen from the bottom - short of dead centre on purpose, because an
animation that finishes exactly as it arrives at the middle was never seen
finishing. Raise it towards 1 to have the animation complete earlier, while
the element is still lower on the screen.

## It needs room to travel in

The element itself does not need explicit height, but its scroll container
does: `progress` is computed from `getBoundingClientRect()` against
`window.innerHeight`, so a page - or a `stageRef` element - with nothing
below the fold to scroll through never produces a value past 0.

## `whenVisible` is a cost cut, not a feature

With it on (the default), an `IntersectionObserver` gates the scroll
listener so an element nobody can see is never measured. Turning it off
means `onProgress` runs on every scroll and resize regardless of where the
element is, which is only worth it if the callback needs to run before the
element is anywhere near the viewport.

## Reduced motion skips straight to finished

Under `prefers-reduced-motion: reduce`, `onProgress(1)` fires once and no
listener is ever attached. Whatever this drives ends up in its arrived
state immediately rather than a frozen mid-transition one - the preference
asks for less movement, not a broken layout.


## API


<!-- generated:api -->

## Signature

```ts
useScrollProgress(ref: RefObject<HTMLElement | null>, onProgress: (progress: number) => void, { finishAt = 0.55, whenVisible = true }: ScrollProgressOptions = {}): void
```

How far an element has travelled through the viewport, from 0 to 1. Different question from `useScrollTurn`, which asks how far the *page* has scrolled. This one is about one element: it reads 0 while the element is still below the fold and 1 once it has risen to `finishAt`, which is what you want for anything that should play as a thing arrives rather than continuously as the page moves. The callback runs in a `requestAnimationFrame` and is expected to write somewhere directly, for the same reason as `useScrollTurn`: sixty state updates a second re-render a subtree sixty times a second. An IntersectionObserver gates the listener rather than driving the value. Observers report crossings, not positions, so they cannot give a smooth progress - but they are the cheapest possible way to stop measuring an element nobody can see.

<!-- /generated:api -->

## Notes

`onProgress` should not be an inline arrow function - it is an effect
dependency, same as `ref`, `finishAt` and `whenVisible`, so a new function
every render tears down and rebuilds the scroll listener and the observer on
every render of whatever calls this hook. Wrap it in `useCallback`.

`finishAt` and `whenVisible` are independent: `whenVisible: false` still
respects `finishAt` for where progress reaches 1, it only changes whether the
listener runs while the element is off screen.


## Examples


Examples are the tab where the component is shown doing a job, not
demonstrating a prop. The API tab already lists the props.

<!-- ::start:showcase demo="use-scroll-progress" height="420" -->
<!-- ::end:showcase -->

Press Compare. The frames are real viewports, so a layout that breaks at 320
breaks here too rather than in somebody's hands.

## In a page

A reading-progress bar pinned to the top of a long article, driven straight
off the article element rather than off `window.scrollY`:

```tsx
import { useCallback, useRef } from "react";
import { useScrollProgress } from "@sushindustries/ui";

export function ReadingProgress({ children }: { children: React.ReactNode }) {
	const articleRef = useRef<HTMLElement>(null);
	const barRef = useRef<HTMLDivElement>(null);

	const paint = useCallback((progress: number) => {
		if (barRef.current) barRef.current.style.transform = `scaleX(${progress})`;
	}, []);

	useScrollProgress(articleRef, paint, { finishAt: 0.05 });

	return (
		<>
			<div className="reading-bar" ref={barRef} />
			<article ref={articleRef}>{children}</article>
		</>
	);
}
```

`finishAt: 0.05` is deliberately near the bottom of the screen rather than
the default 0.55 - a reading bar should read 1 only once the article has
actually scrolled past, not the moment it appears.

## What this example is not

`.reading-bar` needs its own fixed positioning and `transform-origin: left`
in CSS - this hook only produces the number, it writes nothing to layout or
positioning on its own.
