From 954d23a6167b885af8a3ab18f024820e0b80ceb5 Mon Sep 17 00:00:00 2001 From: ameerf-wix Date: Wed, 21 Oct 2026 16:23:45 +0300 Subject: [PATCH] fix(interact): emit timeline-scope for viewProgress effects outside their source A source's view-timeline only resolves for itself and its descendants, so an effect targeting another element got a null timeline and its forwards fill froze it at its end keyframe (e.g. shuttersScroll-out left the element fully clipped). Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/interact/src/core/css.ts | 25 ++++++++++++++++++--- packages/interact/src/core/cssUtils.ts | 9 ++++++++ packages/interact/test/css.spec.ts | 31 ++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/packages/interact/src/core/css.ts b/packages/interact/src/core/css.ts index 524fe113..a8d39ca9 100644 --- a/packages/interact/src/core/css.ts +++ b/packages/interact/src/core/css.ts @@ -30,6 +30,7 @@ import { buildAtPropertyRules, getCustomPropName, buildSequenceListsRule, + buildTimelineScopeRule, LIST_KINDS, listKind, } from './cssUtils'; @@ -81,6 +82,8 @@ type GenerateContext = { keyframeSlots: Map; // only collecting the effects' list entries for getKeyframeSlots, without generating CSS collectSlots: boolean; + // timelines that animate elements outside their source, which need a timeline-scope to resolve + scopedTimelines: string[]; useFirstChild: boolean; plugins?: InteractPluginStyles; }; @@ -543,8 +546,9 @@ function parseInteraction( componentId: '', } as TriggerVariant; const visited = new Set(); + const isViewProgress = trigger === 'viewProgress'; - if (trigger === 'viewProgress') { + if (isViewProgress) { cssRules.push(triggerToCSS(ctx, interaction, motionTrigger.id, visited)); } @@ -582,6 +586,13 @@ function parseInteraction( ), ); + if (isViewProgress) { + const sourceHash = getElementHash(interaction); + if ([...visited].some((targetHash) => targetHash !== sourceHash)) { + ctx.scopedTimelines.push(`--${motionTrigger.id}`); + } + } + visited.forEach((targetHash) => endInteraction(ctx.targetsMap.get(targetHash)!)); return cssRules; @@ -615,6 +626,7 @@ function parseConfig( keyframesMap: new Map(), keyframeSlots: collectSlots ? new Map() : getKeyframeSlots(config), collectSlots, + scopedTimelines: [], useFirstChild, plugins, }; @@ -671,6 +683,7 @@ export function _generate( ): { cssRules: CSSRuleData[]; listsRule: string; + timelineScopeRule: string; keyframes: Map; atProperty: string[]; } { @@ -696,7 +709,9 @@ export function _generate( timelineLength, ); - return { keyframes: ctx.keyframesMap, atProperty, cssRules, listsRule }; + const timelineScopeRule = buildTimelineScopeRule(ctx.scopedTimelines); + + return { keyframes: ctx.keyframesMap, atProperty, cssRules, listsRule, timelineScopeRule }; } /** * Generates CSS for animations from an InteractConfig. @@ -714,13 +729,17 @@ export function _generate( * @returns string containing all of the CSS rules needed for time-based animations */ export function generate(config: InteractConfig, options?: boolean | GenerateOptions): string { - const { cssRules, keyframes, atProperty, listsRule } = _generate(config, options); + const { cssRules, keyframes, atProperty, listsRule, timelineScopeRule } = _generate( + config, + options, + ); const css = [ ...atProperty, ...[...keyframes.entries()].map(([name, keyframes]) => keyframesToCSS(name, keyframes)), ...cssRules.map(CSSRuleToString), listsRule, + timelineScopeRule, ].filter((rule) => rule); return css.join('\n'); diff --git a/packages/interact/src/core/cssUtils.ts b/packages/interact/src/core/cssUtils.ts index 183b6abf..d97e03b9 100644 --- a/packages/interact/src/core/cssUtils.ts +++ b/packages/interact/src/core/cssUtils.ts @@ -290,3 +290,12 @@ export function buildListsRule( return `${joinedSelector} {\n${declarations.map(({ name, value }) => ` ${name}: ${value};`).join('\n')}\n}`; } + +// a source's view-timeline only reaches its descendants, so its other targets need the name scoped on a common ancestor +export function buildTimelineScopeRule(timelineNames: string[]): string { + if (timelineNames.length === 0) { + return ''; + } + + return `:root {\n timeline-scope: ${timelineNames.join(', ')};\n}`; +} diff --git a/packages/interact/test/css.spec.ts b/packages/interact/test/css.spec.ts index c60cee55..017d5875 100644 --- a/packages/interact/test/css.spec.ts +++ b/packages/interact/test/css.spec.ts @@ -1906,6 +1906,37 @@ describe('css._generate', () => { expect(desktop).toMatchObject({ name: '--vw-tmln-1', value: '--trigger-1' }); expect(desktop.media).toContain('min-width: 1024px'); }); + + it('scopes the timelines that animate elements outside their source', () => { + const config: InteractConfig = { + interactions: [ + { key: 'a', trigger: 'viewProgress', effects: [scrub('self')] }, + { key: 'a', trigger: 'viewProgress', effects: [{ ...scrub('other'), key: 'b' }] }, + { + key: 'a', + trigger: 'viewProgress', + sequences: [{ effects: [scrub('seq-self'), { ...scrub('seq-other'), key: 'c' }] }], + }, + ], + }; + + expect(_generate(config).timelineScopeRule).toBe( + ':root {\n timeline-scope: --trigger-1, --trigger-2;\n}', + ); + expect(generate(config)).toContain('timeline-scope: --trigger-1, --trigger-2;'); + }); + + it('does not scope timelines that only animate their source', () => { + const config: InteractConfig = { + interactions: [ + { key: 'a', trigger: 'viewProgress', effects: [scrub('in')] }, + { key: 'b', trigger: 'viewProgress', effects: [scrub('other')] }, + ], + }; + + expect(_generate(config).timelineScopeRule).toBe(''); + expect(generate(config)).not.toContain('timeline-scope'); + }); }); describe('_generate endpoint edge cases', () => {