Repository navigation
fix(interact): scope viewProgress timelines that animate other elements in generate() - #345
Merged
Merged
Conversation
…ts in generate()
generate() names each viewProgress trigger's timeline (`view-timeline: --trigger-N` on the source,
`animation-timeline: --trigger-N` on the target) but a named view timeline is visible only to its
source and its descendants. A target outside that subtree, e.g. a sibling of the source, got a
CSSAnimation with a null timeline that stayed frozen, and the runtime reused it instead of creating
a ViewTimeline animation.
generate() now adds the timeline names of viewProgress interactions that target an element other
than their source to `:root { timeline-scope: ... }`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ameerf-wix
reviewed
Oct 21, 2026
|
|
||
| // a named view timeline is visible only in its source's subtree, so targets outside it, e.g. siblings, | ||
| // need its name scoped to the page (visited holds the source and every target) | ||
| if (trigger === 'viewProgress' && visited.size > 1) { |
Contributor
There was a problem hiding this comment.
let's add here a TODO to have better logic here - for now it is good but we can improve it a bit
ameerf-wix
approved these changes
Oct 21, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Bug fix: a
viewProgresseffect whose target isn't its source or one of the source's descendants (for example, a sibling of the source) never follows the scroll whengenerate()'s CSS is on the page.generate()names eachviewProgresstrigger's timeline. The source getsview-timeline: --trigger-Nand the target getsanimation-timeline: --trigger-N(the view-timeline lists from #338). A named view timeline is visible only to the element that declares it and that element's descendants, andgenerate()emits notimeline-scope. So a target outside the source's subtree gets aCSSAnimationwhosetimelineisnull: it stays frozen at its fill state. At runtime, motion'sgetAnimation()finds that CSS animation by name and reuses it (isCSS), so theviewProgresshandler creates noViewTimelineanimation, and nothing drives the effect.Fix: after
generate()parses aviewProgressinteraction, it checks whether any of its effects or sequences targets an element other than the source (visitedholds the source's hash and every target's hash). If one does, the trigger's timeline name goes into a rule at the end of the output:_generate()returns the rule astimelineScopeRule. It's an empty string when no interaction needs it.Why
:root, and why it's safegenerate()doesn't know the DOM, so it can't find the source's and target's common ancestor.:rootis the one ancestor every source and target share.--trigger-<interactionIndex>), so each scoped name is declared by one source. The exception is a source selector that matches several elements, such as list items. Then the name is ambiguous at:root, and a target outside those elements gets an inactive timeline, which is no worse than thenulltimeline it gets today.:root. I checked this in Chrome 155 on a scratch page with:root { timeline-scope: --trigger-0, --trigger-1 }:nulltoViewTimeline(subject: source)--trigger-1each keep their own descendantnullviewProgresseffect in a config animates its own source, the output is unchanged.Why not change motion instead
I considered having motion skip a CSS animation whose
timelineisnullfor a view-progress trigger, so the JSViewTimelinepath would take over. I didn't do it, for three reasons:fill: forwardsand composites its rotate withadd, so a JS animation on top would stack on the frozen end state unless motion also cancelled the CSS animation.generate()writes is meant to run scroll effects before JS loads, and without JS at all. A runtime fallback leaves the pre-JS render wrong.With the timeline bound, motion's existing reuse is correct: the CSS animation runs, and the runtime creates no duplicate animation.
Related Issue
Found in the Wix viewer (Thunderbolt) on a Wix Studio site that runs interact 2.5.18 with
generate()CSS. A rich-text element is the trigger of twoviewProgressinteractions whose target is its sibling: TurnScroll + TiltScroll at ≥1024px, and FlipScroll + ShuttersScroll below that. The first effect of each pair ran only because the runtime ended up creating a JS animation for it. The second one never moved.Minimal repro
On the site (Chrome 155)
In the "after" column, the rule this branch's
generate()emits for the page's config (:root { timeline-scope: --trigger-0, --trigger-1; }) is appended to the page's SSR interactions style block. The page's interact runtime is unchanged.TiltScroll's CSS animations at 1472px, while the trigger crosses the viewport:
null)null)null)null)ShuttersScroll at 800px behaves the same way: frozen at 1.00 with a
nulltimeline before, and 0.07 → 0.39 → 0.72 → 1.00 after. TurnScroll and FlipScroll keep the same progress as before, but they now run as CSS animations onViewTimeline(subject: trigger)instead of as runtime-created JS animations.Checklist
docs/api/functions.mdTests
Two new specs in
test/css.spec.ts›viewProgress timelines:scopes the timelines that animate other elements to the page: a sibling target (a differentkey), a self-targeting interaction, and a target that shares the source'skeybut has a differentselector. Expectstimeline-scope: --trigger-0, --trigger-2. Without the fix, this fails because the output contains notimeline-scope.does not scope the timelines that only animate their source:viewProgresseffects on the source only, plus aviewEnterthat targets another element. Expects notimeline-scope.With the fix, the full
@wix/interactsuite passes (493/493). eslint, prettier andtsc --noEmitare clean on the changed files.Additional Notes
generate()outputs on one page: trigger names restart at--trigger-0in every config, and two:root { timeline-scope }rules override each other. A page that injects several outputs (for example, a static config plus a runtime config) can still fail to bind cross-elementviewProgresseffects. That's no worse than today. Fixing it needs page-unique trigger names, such as a name prefix option. The site above renders a single config, so it isn't affected.jsdomdoesn't implement scroll-driven animations, so the specs assert the generated CSS. The binding itself was checked in Chrome, as described above.🤖 Generated with Claude Code