Skip to content

Add rangeStart/rangeEnd to scroll() that deactivate JS and native animations#3758

Open
mattgperry wants to merge 1 commit into
mainfrom
fix-3001-scroll-range-deactivation
Open

Add rangeStart/rangeEnd to scroll() that deactivate JS and native animations#3758
mattgperry wants to merge 1 commit into
mainfrom
fix-3001-scroll-range-deactivation

Conversation

@mattgperry

Copy link
Copy Markdown
Collaborator

Problem

scroll() only supported offset, which clamps progress to 0/1 outside the animated range. So a scroll-linked animation keeps its final styles applied after the range ends, which blocks :hover and other CSS from taking over.

Native CSS animation-range (and the WAAPI rangeStart/rangeEnd on Element.animate()) behave differently: outside the range the animation effect is removedgetComputedTiming().progress === null — so the cascade resumes and :hover works. This is exactly the discrepancy reported in #3001:

const animation = animate(box, { opacity: [0, 1] })
scroll(animation, { offset: ["0%", "20%"] })
// progress stays 1 past 20% in Motion; native goes inactive (null) so :hover works

Fix

Adds rangeStart/rangeEnd to ScrollOptions and makes the animation deactivate outside the range on both code paths:

  • Native ScrollTimeline (Chrome 115+): forwards rangeStart/rangeEnd to the WAAPI animation and sets fill: "auto", so the browser removes the effect outside the range.
  • JS observe fallback — used by Safari/Firefox NativeAnimations (no ScrollTimeline) and all JSAnimations (springs, complex values, React motion components): maps the active window from rangeStart/rangeEnd itself and calls a new internal AnimationPlaybackControls.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 0 at rangeStart to 1 at rangeEnd (matching native). rangeStart/rangeEnd accept a WAAPI string ("20%") or a 01 fraction.

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 native ScrollTimeline. When rangeStart/rangeEnd are provided they define the active window on both paths (taking precedence over offset), so the two paths stay in sync.

Tests

  • Unit (Jest) scroll/__tests__/range.test.ts — the JSAnimation/observe path (the path the previous PR missed, and the one JSDOM exercises): verifies in-range remapping, deactivation past rangeEnd (inline style removed), reactivation on scroll-back, and a non-zero rangeStart.
  • E2E (Cypress) scroll-range.ts + dev/react/src/tests/scroll-range.tsx — the issue's mini-animate() repro; asserts the box animates within 0%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 (native ScrollTimeline → WAAPI fill), so both code paths are covered.
  • Full motion-dom (471) and framer-motion client (801) suites pass. No bundle-size impact — setActive tree-shakes out of the motion-value/style-effect size bundles.

Fixes #3001

🤖 Generated with Claude Code

…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-apps

greptile-apps Bot commented Jun 12, 2026

Copy link
Copy Markdown

Greptile Summary

Adds rangeStart/rangeEnd to scroll() so that scroll-linked animations are deactivated outside the active window — removing inline styles and cancelling WAAPI effects — matching the behaviour of native CSS animation-range and fixing #3001. Both code paths are covered: Chrome's native ScrollTimeline path via fill: "auto" and the JS-observe fallback (Safari/Firefox + all JSAnimations) via a new internal setActive() method.

  • JSAnimation.setActive(false) clears the element's inline style so the CSS cascade (:hover, etc.) resumes; NativeAnimation.setActive(false) cancels the WAAPI animation for the same effect.
  • resolveRangeFraction / resolveRangeString translate string | number range values between JS fractions and WAAPI percentage strings, with a documented silent-fallback for unparseable named WAAPI ranges (e.g. "cover 50%") on the JS path.
  • GroupAnimation correctly propagates range deactivation because attachTimeline delegates to each child animation, which each independently sets up its own scrollInfo observer.

Confidence Score: 4/5

Safe 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

Filename Overview
packages/framer-motion/src/render/dom/scroll/attach-animation.ts Core routing logic for native vs JS-observe paths; introduces rangeStart/rangeEnd deactivation via scrollInfo, but named WAAPI range strings silently fall back to 0/1 on the JS path with no warning.
packages/motion-dom/src/animation/JSAnimation.ts Adds setActive() to remove inline styles outside the scroll range; isTransform regex incorrectly matches transformOrigin, so that property's inline style is never cleared on deactivation.
packages/motion-dom/src/animation/NativeAnimation.ts Adds setActive() that cancels the WAAPI animation outside the range; re-entry relies on setting currentTime on an idle animation, which is spec-compliant and confirmed working by E2E tests.
packages/motion-dom/src/animation/GroupAnimation.ts Excludes setActive from runAll type union; deactivation for multi-property GroupAnimations still works correctly because observe() is called per child animation, not on the group itself.
packages/framer-motion/src/render/dom/scroll/utils/range.ts New utility for resolving rangeStart/rangeEnd to fractions and WAAPI strings; handles percentage strings, bare numerics, and 0–1 floats correctly; silently falls back for unparseable WAAPI named ranges.
packages/framer-motion/src/render/dom/scroll/types.ts Adds rangeStart/rangeEnd as string
packages/motion-dom/src/animation/types.ts Adds fill?: string and optional setActive? to the relevant interfaces; clean and minimal change.
packages/framer-motion/src/render/dom/scroll/tests/range.test.ts New unit tests covering the JS-observe path: in-range remapping, deactivation past rangeEnd, re-activation, and non-zero rangeStart — all passing.
packages/framer-motion/cypress/integration/scroll-range.ts E2E tests for native (Chrome) and observe (Electron) paths; verifies opacity within range, deactivation past rangeEnd, and re-activation on scroll-back.
dev/react/src/tests/scroll-range.tsx Dev-server repro page for the E2E tests; straightforward and correct.

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"]
Loading

Reviews (1): Last reviewed commit: "Add rangeStart/rangeEnd to scroll() that..." | Re-trigger Greptile

Comment on lines +596 to 603
function isTransform(name: string) {
return (
name === "x" ||
name === "y" ||
name === "z" ||
/^(transform|translate|rotate|scale|skew)/.test(name)
)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Comment on lines +69 to +90
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] rangeStart and the rangeEnd support

1 participant