Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/interact/docs/api/functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
23 changes: 21 additions & 2 deletions packages/interact/src/core/css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ type GenerateContext = {
targetsMap: TargetsMap;
keyframesMap: Map<string, Keyframe[]>;
keyframeSlots: Map<string, string>;
// 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;
Expand Down Expand Up @@ -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) {

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

ctx.scopedTimelines.push(`--${motionTrigger.id}`);
}

visited.forEach((targetHash) => endInteraction(ctx.targetsMap.get(targetHash)!));

return cssRules;
Expand Down Expand Up @@ -614,6 +622,7 @@ function parseConfig(
targetsMap: new Map<string, TargetContext>(),
keyframesMap: new Map<string, Keyframe[]>(),
keyframeSlots: collectSlots ? new Map() : getKeyframeSlots(config),
scopedTimelines: [],
collectSlots,
useFirstChild,
plugins,
Expand Down Expand Up @@ -673,6 +682,7 @@ export function _generate(
listsRule: string;
keyframes: Map<string, Keyframe[]>;
atProperty: string[];
timelineScopeRule: string;
} {
const { ctx, cssRules } = parseConfig(config, normalizeGenerateOptions(options));

Expand All @@ -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.
Expand All @@ -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');
Expand Down
33 changes: 33 additions & 0 deletions packages/interact/test/css.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading