Throttled Scrub To State
The bridge between a continuous scroll signal and discrete React state. A scrubbed ScrollTrigger calls back on every frame the scroll position moves; this component gates those ticks on elapsed time, on distance travelled, and on the animation frame, so the tree below it renders a few times per scroll instead of a hundred. The boundary frames always get through, which is what stops a fast flick from leaving the state stranded at 0.93.
Throttled Scrub To State
Live preview
The real component, running in this page.
Install
Copies the source into your project. Nothing is imported from us at runtime.
npx shadcn@latest add https://kinetic-seven-taupe.vercel.app/r/throttled-scrub-state.jsonPrompt
The whole technique, written for an agent.
Build it with your agent
A single self-contained brief: the technique, the constants and why they hold, the pitfalls, and the definition of done. Paste it and the component gets written in your own conventions instead of copied in ours.
Source
3 files, exactly as installed.
API
Every parameter, its type and what happens when you change it.
| Parameter | Type | Default | Description |
|---|---|---|---|
| children | (state: ScrubState) => ReactNode | — | Render prop, called only on committed states. |
| interval | number | = 100 | Minimum milliseconds between commits. |
| minDelta | number | = 0.02 | Minimum change in progress before a commit is worth making. |
| scrub | number | boolean | = 0.4 | Seconds of catch-up behind the scroll position. |
| scrollerRef | RefObject<HTMLElement | null> | — | The scroll container that owns the scroll position. Omit for the document. |
| className | string | — | Merged onto the wrapper via cn. |
How it works
The technique in order, not the code in order.
A single
ScrollTrigger.createwithscrubrunsonUpdate, which increments a tick counter in a ref and then decides whether that tick is worth a React render. Refs are used for everything except the committed state, because a ref write does not render.Gate 1 is time:
performance.now() - lastAt < intervalreturns early. A monotonic clock rather thanDate.now(), so a system clock correction cannot produce a negative elapsed time and latch the gate shut.Gate 2 is distance:
Math.abs(p - lastProgress) < minDeltareturns early. A slow drag passes the time gate continuously while moving a fraction of a percent, and that is a render for a change no one can see.Gate 3 is the frame: the passing value is parked in a
pendingref and a singlerequestAnimationFrameis scheduled if one is not already outstanding. ScrollTrigger fires per scroll event, not per paint, so several ticks can clear both other gates inside one frame — coalescing makes that one render.The override: when
p <= 0 || p >= 1the time and distance gates are skipped entirely, because there is no later tick to correct a dropped endpoint. A second guard compares against the last committed value so that sitting at the end of the range does not re-commit on every tick.The committed state carries
commitsandticksalongsideprogress, so the ratio between callbacks and renders is visible in the UI rather than only in a profiler.
Load-bearing constants
| Constant | Value | Why this number |
|---|---|---|
| interval | = 100 | Milliseconds between commits — about 10 renders a second against roughly 120 ticks. Around 16ms the gate stops doing anything useful because it approximates one render per frame; past ~200ms the discrete steps are large enough that a reader notices the value lagging behind the scroll. |
| minDelta | = 0.02 | 2% of the trigger range. Below about 0.005 a slow drag commits on almost every time window; above ~0.05 short triggers can complete their whole range in two or three commits and the readout jumps. |
| scrub | = 0.4 | Under half a second of catch-up. Long enough to smooth a jittery wheel into a continuous ramp, short enough that the committed value still feels attached to the hand. true welds it to the wheel and every scroll spike becomes a tick. |
| boundary test | = p <= 0 || p >= 1 | Inclusive, not strict. ScrollTrigger reports exactly 0 and exactly 1 at the ends, so < 0 and > 1 never match and the override silently does nothing. |
Behaviour
What it does about motion sensitivity, teardown and being finished.
The trigger stays, because the value tracks a scroll the reader is performing rather than animating on its own, but scrub becomes true — welded to the scroll position with no catch-up easing, so nothing continues to move after the reader stops. The gate is unchanged.
The context cleanup kills the ScrollTrigger first and only then cancels any outstanding animation frame, and nulls the pending value. Reversing the order leaves a window in which the still-live trigger schedules a frame that is never cancelled, and that flush calls setState on an unmounted tree — thrown from inside a rAF callback, so the stack trace names no component.
Scroll the panel and watch the two counters: ticks climbs continuously while commits climbs in steps, at roughly a tenth the rate. Flick hard to the very bottom and the progress readout reads exactly 100%, not 93%. Hold still at the bottom and the commit counter stops increasing. Move the scroller by one or two pixels at a time and no commit happens until the distance threshold is crossed.
Pitfalls
- Calling
setStatedirectly fromonUpdate. On a trackpad flick that is 100+ renders a second of the entire subtree; the symptom is that scrolling is smooth on an empty page and drops to single-digit frame rates as soon as a real list is rendered below the component. - Throttling without the boundary override. Release a fast flick and the final tick lands inside the cooldown window, so the state stops at whatever value last got through — a progress bar that never quite reaches 100% and a "complete" branch that never runs.
- Re-committing the boundary on every tick. Once progress is pinned at 1, every subsequent tick passes the override, so idling at the end of the range renders continuously — the opposite of the intent.
- Skipping the rAF coalescer. ScrollTrigger fires per scroll event, so two or three ticks can pass the time and distance gates within a single frame and React renders three times for one painted change.
- Cancelling the frame before killing the trigger. The trigger can schedule a new frame between the two calls, and that flush lands after unmount —
setStateon a dead tree, thrown from a rAF callback with no component in the stack trace. - Storing tick and commit counts in state. They are diagnostics about renders; putting them in state makes every diagnostic a render and the measurement becomes the thing being measured.
Notes
- The continuous signal is still available: anything that needs per-frame precision should be written imperatively from
onUpdate, and only the coarse state should come through the gate. - The render prop receives the committed state only, so consumers cannot accidentally subscribe to the raw tick rate.