Add rangeStart/rangeEnd to scroll() that deactivate JS and native animations#3758
Add rangeStart/rangeEnd to scroll() that deactivate JS and native animations#3758mattgperry wants to merge 1 commit into
Conversation
…imations
scroll() previously clamped progress to 0/1 outside the animated range, so
scroll-linked animations kept their styles applied past the range — blocking
:hover and other CSS, unlike native CSS animation-range / WAAPI rangeStart-
rangeEnd (where the effect is removed and progress becomes null outside).
This adds rangeStart/rangeEnd to ScrollOptions and makes the animation
deactivate outside the range on BOTH paths:
- Native ScrollTimeline (Chrome): forwards rangeStart/rangeEnd to the WAAPI
animation with fill: "auto", so the browser removes the effect outside the
range (as the prior native-only attempt did).
- JS observe fallback (Safari/Firefox NativeAnimation, and all JSAnimations):
maps the active window from rangeStart/rangeEnd and calls a new
AnimationPlaybackControls.setActive() to deactivate outside it. NativeAnimation
cancels its WAAPI effect; JSAnimation removes its rendered inline style so the
CSS cascade can take over. Re-entering the range reactivates and re-applies.
This addresses the reason the previous PR was closed ("only works for
NativeAnimation animations") by covering JSAnimations too.
Fixes #3001
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Greptile SummaryAdds
Confidence Score: 4/5Safe to merge for the common case; two edge-case gaps in deactivation correctness are worth addressing before wide adoption. The core deactivation mechanic is solid and well-tested across both code paths. Two gaps exist: the isTransform regex matches transformOrigin, so a transformOrigin animation would fail to clear its inline style on deactivation (and would incorrectly clear transform instead); and named WAAPI range strings like 'cover 50%' silently fall back to 0/1 on the JS-observe path, where the same string type that carries them also accepts simple percentages — the divergence between the two paths is not signalled to the caller. packages/motion-dom/src/animation/JSAnimation.ts (isTransform false positive) and packages/framer-motion/src/render/dom/scroll/attach-animation.ts (silent fallback for named WAAPI range strings) Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A["scroll(animation, { rangeStart, rangeEnd })"] --> B{hasUserRange?}
B -- No --> C{useNative?}
B -- Yes --> D{useNative AND supportsScrollTimeline?}
C -- Yes --> E["Native ScrollTimeline (unchanged offset path)"]
C -- No --> F["observeTimeline() JS progress scrubbing"]
D -- Yes --> G["Native ScrollTimeline + fill: auto + rangeStart/rangeEnd → browser deactivates outside range"]
D -- No --> H["scrollInfo() observer JS range window mapping"]
H --> I{progress in range?}
I -- Yes --> J["setActive(true) remap time to range fraction"]
I -- No --> K["setActive(false)"]
K --> L{Animation type?}
L -- JSAnimation --> M["removeProperty(transform or camelToDash) cascade resumes"]
L -- NativeAnimation --> N["cancel() cascade resumes"]
J --> O["valueAnimation.time = iterDuration x (progress - start) / span"]
Reviews (1): Last reviewed commit: "Add rangeStart/rangeEnd to scroll() that..." | Re-trigger Greptile |
| function isTransform(name: string) { | ||
| return ( | ||
| name === "x" || | ||
| name === "y" || | ||
| name === "z" || | ||
| /^(transform|translate|rotate|scale|skew)/.test(name) | ||
| ) | ||
| } |
There was a problem hiding this comment.
isTransform matches transformOrigin, clearing the wrong CSS property
/^(transform|...)/.test("transformOrigin") returns true, so setActive(false) calls element.style.removeProperty("transform") instead of element.style.removeProperty("transform-origin"). The transform-origin inline style is never removed on deactivation, so the CSS cascade cannot take over for that property — and any existing inline transform value on the element is also incorrectly wiped. transformPerspective has the same issue since it too starts with "transform" but renders to its own CSS custom property path in some versions.
| if (hasUserRange) { | ||
| return scrollInfo((info) => { | ||
| const axis = info[options.axis] | ||
| const progress = axis.scrollLength | ||
| ? axis.current / axis.scrollLength | ||
| : 0 | ||
|
|
||
| if ( | ||
| progress < rangeStartFraction || | ||
| progress > rangeEndFraction | ||
| ) { | ||
| valueAnimation.setActive?.(false) | ||
| return | ||
| } | ||
|
|
||
| valueAnimation.setActive?.(true) | ||
| valueAnimation.time = | ||
| valueAnimation.iterationDuration * | ||
| (rangeSpan > 0 | ||
| ? (progress - rangeStartFraction) / rangeSpan | ||
| : 0) | ||
| }, options) |
There was a problem hiding this comment.
WAAPI named range strings silently ignored on JS-observe path
resolveRangeFraction("cover 50%", fallback) returns the fallback (0 or 1) because parseFloat("cover 50%") is NaN. When a user on Safari or Firefox passes rangeStart: "cover 50%" — a valid WAAPI string that the API's string | number type explicitly accepts — the JS path silently treats it as 0 instead of raising any warning. The native path (Chrome) forwards the string unchanged to the browser, so the two code paths diverge invisibly. A console warning when a WAAPI-keyword string is detected on the JS path would prevent silent misbehaviour.
Problem
scroll()only supportedoffset, which clamps progress to0/1outside the animated range. So a scroll-linked animation keeps its final styles applied after the range ends, which blocks:hoverand other CSS from taking over.Native CSS
animation-range(and the WAAPIrangeStart/rangeEndonElement.animate()) behave differently: outside the range the animation effect is removed —getComputedTiming().progress === null— so the cascade resumes and:hoverworks. This is exactly the discrepancy reported in #3001:Fix
Adds
rangeStart/rangeEndtoScrollOptionsand makes the animation deactivate outside the range on both code paths:ScrollTimeline(Chrome 115+): forwardsrangeStart/rangeEndto the WAAPI animation and setsfill: "auto", so the browser removes the effect outside the range.NativeAnimations (noScrollTimeline) and allJSAnimations (springs, complex values, Reactmotioncomponents): maps the active window fromrangeStart/rangeEnditself and calls a new internalAnimationPlaybackControls.setActive()to deactivate outside it:NativeAnimation.setActive(false)cancels the WAAPI effect so the cascade resumes.JSAnimation.setActive(false)removes its rendered inline style (the value stays bound, so re-entering the range re-applies it via a render).Within the range, progress is remapped so the animation plays from
0atrangeStartto1atrangeEnd(matching native).rangeStart/rangeEndaccept a WAAPI string ("20%") or a0–1fraction.Why a re-attempt, and what's different
The earlier attempt (#3646 / #3713) was closed with "this only works for NativeAnimation animations." That's the core fix here: the deactivation now also works for
JSAnimations via the shared observe path, so the behavior is consistent across Chrome, Safari/Firefox, and JS-driven animations — not just Chrome's nativeScrollTimeline. WhenrangeStart/rangeEndare provided they define the active window on both paths (taking precedence overoffset), so the two paths stay in sync.Tests
scroll/__tests__/range.test.ts— theJSAnimation/observe path (the path the previous PR missed, and the one JSDOM exercises): verifies in-range remapping, deactivation pastrangeEnd(inline style removed), reactivation on scroll-back, and a non-zerorangeStart.scroll-range.ts+dev/react/src/tests/scroll-range.tsx— the issue's mini-animate()repro; asserts the box animates within0%–20%and reverts to its base CSS opacity past the range. Passing on React 18 + 19 in both Electron (observe fallback →NativeAnimation.setActive) and real Chrome (nativeScrollTimeline→ WAAPIfill), so both code paths are covered.motion-dom(471) andframer-motionclient (801) suites pass. No bundle-size impact —setActivetree-shakes out of themotion-value/style-effectsize bundles.Fixes #3001
🤖 Generated with Claude Code