diff --git a/packages/interact/docs/api/functions.md b/packages/interact/docs/api/functions.md index c95a29ea..74c1635f 100644 --- a/packages/interact/docs/api/functions.md +++ b/packages/interact/docs/api/functions.md @@ -289,6 +289,8 @@ For `triggerType: 'repeat'`/`'alternate'`/`'state'`, manually apply the starting For `viewProgress` interactions, `generate()` emits `view-timeline` declarations and `animation-timeline`/`animation-range` custom properties. This produces fully native scroll-driven animations that work before JavaScript loads — the browser drives the animation based on the element's scroll position, with zero JS overhead. +A named view timeline is visible only to its source element and its descendants, so when an effect targets another element (e.g. a sibling of the source), `generate()` also adds the trigger's timeline name to a `:root { timeline-scope: ... }` rule. + ### Examples #### Entrance animation (viewEnter) diff --git a/packages/interact/src/core/css.ts b/packages/interact/src/core/css.ts index 524fe113..47862bad 100644 --- a/packages/interact/src/core/css.ts +++ b/packages/interact/src/core/css.ts @@ -79,6 +79,8 @@ type GenerateContext = { targetsMap: TargetsMap; keyframesMap: Map; keyframeSlots: Map; + // view timelines whose targets may be outside their source's subtree + scopedTimelines: string[]; // only collecting the effects' list entries for getKeyframeSlots, without generating CSS collectSlots: boolean; useFirstChild: boolean; @@ -582,6 +584,12 @@ function parseInteraction( ), ); + // 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) { + ctx.scopedTimelines.push(`--${motionTrigger.id}`); + } + visited.forEach((targetHash) => endInteraction(ctx.targetsMap.get(targetHash)!)); return cssRules; @@ -614,6 +622,7 @@ function parseConfig( targetsMap: new Map(), keyframesMap: new Map(), keyframeSlots: collectSlots ? new Map() : getKeyframeSlots(config), + scopedTimelines: [], collectSlots, useFirstChild, plugins, @@ -673,6 +682,7 @@ export function _generate( listsRule: string; keyframes: Map; atProperty: string[]; + timelineScopeRule: string; } { const { ctx, cssRules } = parseConfig(config, normalizeGenerateOptions(options)); @@ -696,7 +706,12 @@ export function _generate( timelineLength, ); - return { keyframes: ctx.keyframesMap, atProperty, cssRules, listsRule }; + // trigger names are unique in a config, and a target inside its source still resolves the source's timeline first + const timelineScopeRule = ctx.scopedTimelines.length + ? `:root {\n timeline-scope: ${ctx.scopedTimelines.join(', ')};\n}` + : ''; + + 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/test/css.spec.ts b/packages/interact/test/css.spec.ts index c60cee55..a9052249 100644 --- a/packages/interact/test/css.spec.ts +++ b/packages/interact/test/css.spec.ts @@ -1906,6 +1906,39 @@ describe('css._generate', () => { expect(desktop).toMatchObject({ name: '--vw-tmln-1', value: '--trigger-1' }); expect(desktop.media).toContain('min-width: 1024px'); }); + + // a named view timeline is only visible to its source's subtree, so a sibling target can't find it + it('scopes the timelines that animate other elements to the page', () => { + const css = generate({ + interactions: [ + { key: 'a', trigger: 'viewProgress', effects: [{ ...scrub('sibling'), key: 'b' }] }, + { key: 'a', trigger: 'viewProgress', effects: [scrub('self')] }, + { + key: 'c', + selector: '.source', + trigger: 'viewProgress', + effects: [{ ...scrub('other'), selector: '.target' }], + }, + ], + }); + + expect(css).toContain(':root {\n timeline-scope: --trigger-0, --trigger-2;\n}'); + }); + + it('does not scope the timelines that only animate their source', () => { + const css = generate({ + interactions: [ + { + key: 'a', + trigger: 'viewProgress', + effects: [scrub('in'), { ...scrub('out'), key: 'a' }], + }, + { key: 'a', trigger: 'viewEnter', effects: [{ ...scrub('enter'), key: 'b' }] }, + ], + }); + + expect(css).not.toContain('timeline-scope'); + }); }); describe('_generate endpoint edge cases', () => {