Skip to content

fix(interact): scope viewProgress timelines that animate other elements in generate() - #345

Merged
eliranhe merged 1 commit into
masterfrom
fix/view-progress-timeline-scope
Oct 22, 2026
Merged

eliranhe merged 1 commit into
masterfrom
fix/view-progress-timeline-scope

Conversation

@eliranhe

Copy link
Copy Markdown
Contributor

Description

Bug fix: a viewProgress effect whose target isn't its source or one of the source's descendants (for example, a sibling of the source) never follows the scroll when generate()'s CSS is on the page.

generate() names each viewProgress trigger's timeline. The source gets view-timeline: --trigger-N and the target gets animation-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, and generate() emits no timeline-scope. So a target outside the source's subtree gets a CSSAnimation whose timeline is null: it stays frozen at its fill state. At runtime, motion's getAnimation() finds that CSS animation by name and reuses it (isCSS), so the viewProgress handler creates no ViewTimeline animation, and nothing drives the effect.

Fix: after generate() parses a viewProgress interaction, it checks whether any of its effects or sequences targets an element other than the source (visited holds 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:

:root {
  timeline-scope: --trigger-0, --trigger-1;
}

_generate() returns the rule as timelineScopeRule. It's an empty string when no interaction needs it.

Why :root, and why it's safe

  • generate() doesn't know the DOM, so it can't find the source's and target's common ancestor. :root is the one ancestor every source and target share.
  • Trigger names are unique within a config (--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 the null timeline it gets today.
  • A named timeline resolves to the nearest inclusive ancestor that declares the name. A target inside its source, which is the common case, still binds to its own source before it reaches :root. I checked this in Chrome 155 on a scratch page with :root { timeline-scope: --trigger-0, --trigger-1 }:
    • a descendant of the source binds to its source, with or without the scope
    • a sibling of the source goes from null to ViewTimeline(subject: source)
    • two repeated sources that both declare --trigger-1 each keep their own descendant
    • an element outside both repeated sources stays null
  • The rule is only emitted when needed. If every viewProgress effect 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 timeline is null for a view-progress trigger, so the JS ViewTimeline path would take over. I didn't do it, for three reasons:

  • The dead CSS animation keeps applying its fill. In the repro, TiltScroll is fill: forwards and composites its rotate with add, so a JS animation on top would stack on the frozen end state unless motion also cancelled the CSS animation.
  • The CSS that 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.
  • It would need a motion release in addition to an interact release.

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 two viewProgress interactions 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

<style>
  @keyframes fade { from { opacity: 0 } to { opacity: 1 } }
  .source { view-timeline: --trigger-0; }
  .target { animation: fade linear both; animation-timeline: --trigger-0; }
</style>
<div class="source">…</div>
<div class="target">…</div>
<!-- target.getAnimations()[0].timeline === null -->
<!-- with :root { timeline-scope: --trigger-0 }: a ViewTimeline whose subject is .source -->

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:

scrollY before: progress (timeline) after
1653 1.00 (null) 0.05
1803 1.00 (null) 0.37
1953 1.00 (null) 0.68
2103 1.00 (null) 1.00

ShuttersScroll at 800px behaves the same way: frozen at 1.00 with a null timeline 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 on ViewTimeline(subject: trigger) instead of as runtime-created JS animations.

Checklist

  • I have read the Contributing Guide
  • I have added/updated tests for my changes (if applicable)
  • I have updated documentation/rules/skills (if applicable): one sentence under "Scroll-driven CSS (viewProgress)" in docs/api/functions.md

Tests

Two new specs in test/css.spec.ts › viewProgress timelines:

  • scopes the timelines that animate other elements to the page: a sibling target (a different key), a self-targeting interaction, and a target that shares the source's key but has a different selector. Expects timeline-scope: --trigger-0, --trigger-2. Without the fix, this fails because the output contains no timeline-scope.
  • does not scope the timelines that only animate their source: viewProgress effects on the source only, plus a viewEnter that targets another element. Expects no timeline-scope.

With the fix, the full @wix/interact suite passes (493/493). eslint, prettier and tsc --noEmit are clean on the changed files.

Additional Notes

  • Several generate() outputs on one page: trigger names restart at --trigger-0 in 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-element viewProgress effects. 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.
  • jsdom doesn'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

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

// 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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

let's add here a TODO to have better logic here - for now it is good but we can improve it a bit

@eliranhe
eliranhe marked this pull request as ready for review October 21, 2026 14:26
@eliranhe
eliranhe merged commit e20900f into master Oct 22, 2026
1 check passed
@eliranhe
eliranhe deleted the fix/view-progress-timeline-scope branch October 22, 2026 06:12
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.

2 participants