diff --git a/.cursor/plans/fix_entrance_fouc_fc913a84.plan.md b/.cursor/plans/fix_entrance_fouc_fc913a84.plan.md new file mode 100644 index 00000000..5be4e220 --- /dev/null +++ b/.cursor/plans/fix_entrance_fouc_fc913a84.plan.md @@ -0,0 +1,43 @@ +--- +name: Fix entrance FOUC +overview: Prevent delayed entrance flashes without changing IntersectionObserver geometry by making the transient neutral styles author-important, using real zero delays, and giving entrance effects backwards fill by default. Align validation, documentation, and Interactor guidance with the runtime behavior. +todos: + - id: important-initial-css + content: Emit author-important transient FOUC declarations and preserve true zero animation delays + status: completed + - id: entrance-fill-defaults + content: Default every entrance preset descriptor to backwards fill while preserving explicit overrides + status: completed + - id: validation-guidance + content: Add backwards-fill validation guidance and align Interact docs, rules, and Interactor skill + status: completed + - id: regression-tests + content: Cover CSS serialization, timing, all entrance presets, validator behavior, and run targeted verification + status: completed +isProject: false +--- + +# Fix Delayed Entrance FOUC + +## Implementation + +- Extend the internal declaration shape in [`packages/interact/src/types/css.ts`](packages/interact/src/types/css.ts) with an `important` flag and serialize it in [`packages/interact/src/core/cssUtils.ts`](packages/interact/src/core/cssUtils.ts). Mark every [`DEFAULT_INITIAL`](packages/interact/src/core/css.ts) declaration important so `visibility: hidden` and neutral transform properties override pre-trigger animation values, while retaining the existing `:not([data-interact-enter])` lifecycle. +- In [`packages/motion/src/api/cssAnimations.ts`](packages/motion/src/api/cssAnimations.ts), replace the synthetic `delay || 1` fallback with `delay ?? 0`. Do not add the proposed `0.1ms` initial override, because it would alter computed sequence timing and could move a playing animation back from its active phase into its delay phase. +- Add a small `getEntranceFill()` helper in [`packages/motion-presets/src/utils.ts`](packages/motion-presets/src/utils.ts), returning `options.fill ?? 'backwards'`. Apply it after `...options` to every animation descriptor in all 19 modules under [`packages/motion-presets/src/library/entrance/`](packages/motion-presets/src/library/entrance/) so both CSS and WAAPI paths default to backwards fill while explicit `none`, `forwards`, or `both` values remain authoritative. + +## Validation and guidance + +- Add an informational `RECOMMENDED_FILL_BACKWARDS` semantic check in [`packages/interact-validate/src/semantic/recommendedPatterns.ts`](packages/interact-validate/src/semantic/recommendedPatterns.ts), wire it through [`collectSemanticWarnings.ts`](packages/interact-validate/src/semantic/collectSemanticWarnings.ts) and [`errors.ts`](packages/interact-validate/src/errors.ts), and recommend explicit `backwards` or `both` for effective `viewEnter`/`once` animation effects, including nested sequence effects. Keep this advisory so preset defaults remain a safety net rather than a schema requirement. +- Update the authoritative entrance/FOUC guidance and generated CSS examples in [`packages/interact/rules/viewenter.md`](packages/interact/rules/viewenter.md), [`packages/interact/rules/full-lean.md`](packages/interact/rules/full-lean.md), [`packages/interact/docs/api/functions.md`](packages/interact/docs/api/functions.md), and [`packages/interact/docs/examples/entrance-animations.md`](packages/interact/docs/examples/entrance-animations.md): explain the important neutral pre-trigger rule, use `fill: 'backwards'` for `once`, and reserve `both` for effects that must retain their final keyframe. +- Update the repository Interactor source in [`skills/interactor/SKILL.md`](skills/interactor/SKILL.md) plus its `config-schema`, `triggers`, `integration-recipes`, and `validate` references. Make explicit backwards fill an invariant in generated `viewEnter`/`once` configs and update the relevant eval expectation; do not manually edit external installed skill snapshots. + +## Regression coverage + +- Update [`packages/interact/test/css.spec.ts`](packages/interact/test/css.spec.ts) and [`cssUtils.spec.ts`](packages/interact/test/cssUtils.spec.ts) to verify important initial declarations are emitted only for eligible same-element `viewEnter`/`once` effects and disappear from the post-start selector. +- Update [`packages/motion/test/motion.spec.ts`](packages/motion/test/motion.spec.ts) to assert omitted/zero delay produces `0ms`, while positive delays remain unchanged. +- Add centralized entrance-preset tests covering all 19 exports: every generated descriptor defaults to `backwards`, and an explicit fill value is preserved. Retain existing per-preset behavior tests and add coverage for the currently untested `ExpandIn` export through this matrix. +- Add validator tests for direct effects, referenced registry effects, sequence effects, implicit `once`, accepted `backwards`/`both`, and informational severity/category overrides. + +## Verification + +- Run `nvm use`, then targeted tests and type checks for `@wix/motion`, `@wix/motion-presets`, `@wix/interact`, and `@wix/interact-validate`; finish with repository lint/format checks for touched files. diff --git a/packages/interact-validate/README.md b/packages/interact-validate/README.md index 53648fc7..5d6de63d 100644 --- a/packages/interact-validate/README.md +++ b/packages/interact-validate/README.md @@ -194,25 +194,26 @@ The single source of truth for every code the validator emits. The agent-facing These encode statically-detectable authoring pitfalls from the trigger rule files. Each belongs to a [rule category](#severity-model), so set the category to `'off'` to silence it or `'error'` to make it fail `valid`. -| Code | Trigger | Rule category | -| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------ | -| `UNUSED_EFFECT` | A `config.effects` entry is never referenced. | `UNUSED_DEFINITION` | -| `UNUSED_SEQUENCE` | A `config.sequences` entry is never referenced. | `UNUSED_DEFINITION` | -| `UNUSED_CONDITION` | A `config.conditions` entry is never referenced. | `UNUSED_DEFINITION` | -| `DUPLICATE_KEYFRAME_NAME` | A `keyframeEffect.name` is reused across effects. | `UNIQUE_DEFINITION_IDS` | -| `SAME_ELEMENT_RETRIGGER` | `viewEnter` with a non-`once` `triggerType` on the same source+target element. | `SAME_ELEMENT_RETRIGGER` | -| `HIT_AREA_SHIFT` | `hover`/`pointerMove` `keyframeEffect` with a `translate`/`scale`/`matrix` transform on the same source+target element. | `HIT_AREA_SHIFT` | -| `SCROLL_PRESET_MISSING_RANGE` | A `*Scroll` `namedEffect` on `viewProgress` omits `range`. | `SCROLL_RANGE` | -| `SCROLL_PRESET_BAD_RANGE` | A scroll preset `range` is not `'in'`/`'out'`/`'continuous'`. | `SCROLL_RANGE` | -| `ANIMATION_END_SELF_REFERENCE` | An `animationEnd` interaction waits on an effect it also produces (never starts). | `ANIMATION_END_GRAPH` | -| `LIST_ITEM_SELECTOR_WITHOUT_CONTAINER` | `listItemSelector` present without `listContainer` (inert). | `ELEMENT_SELECTION` | -| `REDUNDANT_SELECTOR_WITH_LIST_ITEM` | `selector` ignored when `listContainer` + `listItemSelector` are both present. | `ELEMENT_SELECTION` | -| `EMPTY_STYLE_PROPERTIES` | A state effect's `transition.styleProperties` / `transitionProperties` is `[]` (toggles nothing). | `STATE_EFFECT` | -| `STATE_REMOVE_WITHOUT_EFFECT_ID` | `stateAction: 'remove'` with no `effectId` to pair with a matching `'add'`. | `STATE_EFFECT` | -| `RECOMMENDED_FILL_BOTH` | A scrubbed (`viewProgress`/`pointerMove`) or toggling (`alternate`/`repeat`/`state`) effect omits `fill: 'both'`. | `RECOMMENDED_FILL` | -| `POINTER_AXIS_IGNORED` | `pointerMove` `params.axis` set on a `namedEffect`/`customEffect` (axis only applies to `keyframeEffect`). | `POINTER_AXIS` | -| `KEYFRAME_PROP_NOT_CAMEL_CASE` | A `keyframeEffect` property name is kebab-case (not WAAPI camelCase). | `KEYFRAME_STYLE` | -| `INVALID_INSET` | `viewEnter` `params.inset` is not 1–4 CSS lengths/percentages. | `VIEW_INSET` | +| Code | Trigger | Rule category | +| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ | +| `UNUSED_EFFECT` | A `config.effects` entry is never referenced. | `UNUSED_DEFINITION` | +| `UNUSED_SEQUENCE` | A `config.sequences` entry is never referenced. | `UNUSED_DEFINITION` | +| `UNUSED_CONDITION` | A `config.conditions` entry is never referenced. | `UNUSED_DEFINITION` | +| `DUPLICATE_KEYFRAME_NAME` | A `keyframeEffect.name` is reused across effects. | `UNIQUE_DEFINITION_IDS` | +| `SAME_ELEMENT_RETRIGGER` | `viewEnter` with a non-`once` `triggerType` on the same source+target element. | `SAME_ELEMENT_RETRIGGER` | +| `HIT_AREA_SHIFT` | `hover`/`pointerMove` `keyframeEffect` with a `translate`/`scale`/`matrix` transform on the same source+target element. | `HIT_AREA_SHIFT` | +| `SCROLL_PRESET_MISSING_RANGE` | A `*Scroll` `namedEffect` on `viewProgress` omits `range`. | `SCROLL_RANGE` | +| `SCROLL_PRESET_BAD_RANGE` | A scroll preset `range` is not `'in'`/`'out'`/`'continuous'`. | `SCROLL_RANGE` | +| `ANIMATION_END_SELF_REFERENCE` | An `animationEnd` interaction waits on an effect it also produces (never starts). | `ANIMATION_END_GRAPH` | +| `LIST_ITEM_SELECTOR_WITHOUT_CONTAINER` | `listItemSelector` present without `listContainer` (inert). | `ELEMENT_SELECTION` | +| `REDUNDANT_SELECTOR_WITH_LIST_ITEM` | `selector` ignored when `listContainer` + `listItemSelector` are both present. | `ELEMENT_SELECTION` | +| `EMPTY_STYLE_PROPERTIES` | A state effect's `transition.styleProperties` / `transitionProperties` is `[]` (toggles nothing). | `STATE_EFFECT` | +| `STATE_REMOVE_WITHOUT_EFFECT_ID` | `stateAction: 'remove'` with no `effectId` to pair with a matching `'add'`. | `STATE_EFFECT` | +| `RECOMMENDED_FILL_BOTH` | A scrubbed (`viewProgress`/`pointerMove`) or toggling (`alternate`/`repeat`/`state`) effect omits `fill: 'both'`. | `RECOMMENDED_FILL` | +| `RECOMMENDED_FILL_BACKWARDS` | A `viewEnter` + `once` named/keyframe effect targeting another element or using a same-element delay omits `fill: 'backwards'` or `'both'`. | `RECOMMENDED_FILL` | +| `POINTER_AXIS_IGNORED` | `pointerMove` `params.axis` set on a `namedEffect`/`customEffect` (axis only applies to `keyframeEffect`). | `POINTER_AXIS` | +| `KEYFRAME_PROP_NOT_CAMEL_CASE` | A `keyframeEffect` property name is kebab-case (not WAAPI camelCase). | `KEYFRAME_STYLE` | +| `INVALID_INSET` | `viewEnter` `params.inset` is not 1–4 CSS lengths/percentages. | `VIEW_INSET` | ## Usage recipes diff --git a/packages/interact-validate/src/errors.ts b/packages/interact-validate/src/errors.ts index 2d4820c5..d2bae0c1 100644 --- a/packages/interact-validate/src/errors.ts +++ b/packages/interact-validate/src/errors.ts @@ -29,6 +29,7 @@ const RULE_CODE_MAP: Record = { EMPTY_STYLE_PROPERTIES: 'STATE_EFFECT', STATE_REMOVE_WITHOUT_EFFECT_ID: 'STATE_EFFECT', RECOMMENDED_FILL_BOTH: 'RECOMMENDED_FILL', + RECOMMENDED_FILL_BACKWARDS: 'RECOMMENDED_FILL', POINTER_AXIS_IGNORED: 'POINTER_AXIS', KEYFRAME_PROP_NOT_CAMEL_CASE: 'KEYFRAME_STYLE', INVALID_INSET: 'VIEW_INSET', diff --git a/packages/interact-validate/src/semantic/fouc.ts b/packages/interact-validate/src/semantic/fouc.ts index 7761158c..7d2f585b 100644 --- a/packages/interact-validate/src/semantic/fouc.ts +++ b/packages/interact-validate/src/semantic/fouc.ts @@ -11,7 +11,7 @@ import { RETRIGGER_TYPES } from '../types'; const DISCRETE_TRIGGERS = ['hover', 'click', 'interest', 'activate']; const HIT_AREA_TRANSFORM = /(translate|scale|matrix)/; -function targetsSameElementAsSource(owner: AnyInteraction, effect: AnyEffect): boolean { +export function targetsSameElementAsSource(owner: AnyInteraction, effect: AnyEffect): boolean { if (effect.key !== undefined && effect.key !== owner.key) return false; const refiners = ['selector', 'listContainer', 'listItemSelector'] as const; for (const field of refiners) { diff --git a/packages/interact-validate/src/semantic/recommendedPatterns.ts b/packages/interact-validate/src/semantic/recommendedPatterns.ts index f0a7ccf0..decf7bab 100644 --- a/packages/interact-validate/src/semantic/recommendedPatterns.ts +++ b/packages/interact-validate/src/semantic/recommendedPatterns.ts @@ -1,7 +1,11 @@ import type { Path, SemanticIssue, AnyEffect, AnyInteraction } from '../types'; import { RETRIGGER_TYPES } from '../types'; -// recommended `fill: 'both'` for scrubbed and toggling effects +function isKeyframeEffect(effect: AnyEffect): boolean { + return !!(effect.namedEffect || effect.keyframeEffect); +} + +// recommended fill for scrubbed, toggling, and viewEnter entrance effects export function checkRecommendedFill( path: Path, effect: AnyEffect, @@ -11,16 +15,34 @@ export function checkRecommendedFill( const isScrubbed = owner?.trigger === 'viewProgress' || owner?.trigger === 'pointerMove'; const isToggling = effect.triggerType !== undefined && RETRIGGER_TYPES.includes(effect.triggerType); - if (!isScrubbed && !isToggling) return []; - const reason = isScrubbed - ? `${owner?.trigger} (scrubbed) effects` - : `triggerType '${effect.triggerType}' effects`; + + if (isScrubbed || isToggling) { + const reason = isScrubbed + ? `${owner?.trigger} (scrubbed) effects` + : `triggerType '${effect.triggerType}' effects`; + return [ + { + code: 'custom', + params: { domainCode: 'RECOMMENDED_FILL_BOTH' }, + path: [...path, 'fill'], + message: `Include \`fill: 'both'\` for ${reason} so the effect stays applied and is not garbage-collected.`, + severity: 'info', + }, + ]; + } + + if (!owner || owner.trigger !== 'viewEnter') return []; + const triggerType = effect.triggerType ?? 'once'; + if (triggerType !== 'once') return []; + if (!isKeyframeEffect(effect)) return []; + if (effect.fill === 'backwards' || effect.fill === 'both') return []; + return [ { code: 'custom', - params: { domainCode: 'RECOMMENDED_FILL_BOTH' }, + params: { domainCode: 'RECOMMENDED_FILL_BACKWARDS' }, path: [...path, 'fill'], - message: `Include \`fill: 'both'\` for ${reason} so the effect stays applied and is not garbage-collected.`, + message: `Include \`fill: 'backwards'\` (or \`'both'\` when the final keyframe must persist) so the starting keyframe applies before the animation starts on a target without FOUC hiding rules.`, severity: 'info', }, ]; diff --git a/packages/interact-validate/src/types.ts b/packages/interact-validate/src/types.ts index f624292f..2d9c6140 100644 --- a/packages/interact-validate/src/types.ts +++ b/packages/interact-validate/src/types.ts @@ -40,6 +40,7 @@ export type AnyEffect = { triggerType?: string; stateAction?: string; fill?: string; + delay?: number; namedEffect?: { type?: string; range?: unknown; [k: string]: unknown }; keyframeEffect?: { name?: string; keyframes?: Array> }; transition?: { styleProperties?: unknown[] }; diff --git a/packages/interact-validate/test/rules/recommendedFillBackwards.spec.ts b/packages/interact-validate/test/rules/recommendedFillBackwards.spec.ts new file mode 100644 index 00000000..30983a3b --- /dev/null +++ b/packages/interact-validate/test/rules/recommendedFillBackwards.spec.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from 'vitest'; +import { validateInteractConfig } from '../../src'; + +const CODE = 'RECOMMENDED_FILL_BACKWARDS'; + +describe('recommendedFillBackwards — RECOMMENDED_FILL_BACKWARDS', () => { + it('warns for a delayed same-element viewEnter once effect that omits fill', () => { + const result = validateInteractConfig({ + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + effects: [ + { + namedEffect: { type: 'FadeIn' }, + duration: 400, + delay: 200, + triggerType: 'once', + }, + ], + }, + ], + }); + + const err = result.errors.find((e) => e.code === CODE); + expect(err).toBeDefined(); + expect(err?.severity).toBe('info'); + expect(err?.path).toEqual(['interactions', 0, 'effects', 0, 'fill']); + expect(result.valid).toBe(true); + }); + + it('warns for a separate-target viewEnter effect with implicit once triggerType', () => { + const result = validateInteractConfig({ + interactions: [ + { + key: 'source', + trigger: 'viewEnter', + effects: [{ key: 'target', namedEffect: { type: 'FadeIn' }, duration: 400 }], + }, + ], + }); + + expect(result.errors.some((e) => e.code === CODE)).toBe(true); + }); + + it('warns for a viewEnter once keyframeEffect without fill', () => { + const result = validateInteractConfig({ + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + effects: [ + { + keyframeEffect: { name: 'fade', keyframes: [{ opacity: '0' }, { opacity: '1' }] }, + duration: 400, + delay: 200, + }, + ], + }, + ], + }); + + expect(result.errors.some((e) => e.code === CODE)).toBe(true); + }); + + it('warns for a viewEnter once sequence effect without fill', () => { + const result = validateInteractConfig({ + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + sequences: [ + { + effects: [ + { + selector: '.card', + namedEffect: { type: 'FadeIn' }, + duration: 400, + }, + ], + }, + ], + }, + ], + }); + + expect( + result.errors.some( + (e) => e.code === CODE && e.path.join('.') === 'interactions.0.sequences.0.effects.0.fill', + ), + ).toBe(true); + }); + + describe('no warning for the documented valid patterns', () => { + it('does not warn when fill: backwards is present', () => { + const result = validateInteractConfig({ + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + effects: [ + { + fill: 'backwards', + namedEffect: { type: 'FadeIn' }, + duration: 400, + triggerType: 'once', + }, + ], + }, + ], + }); + + expect(result.errors.filter((e) => e.code === CODE)).toHaveLength(0); + }); + + it('does not warn when fill: both is present', () => { + const result = validateInteractConfig({ + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + effects: [ + { + fill: 'both', + namedEffect: { type: 'FadeIn' }, + duration: 400, + triggerType: 'once', + }, + ], + }, + ], + }); + + expect(result.errors.filter((e) => e.code === CODE)).toHaveLength(0); + }); + + it('does not warn for a customEffect entrance', () => { + const result = validateInteractConfig({ + interactions: [ + { + key: 'source', + trigger: 'viewEnter', + effects: [{ key: 'target', customEffect: () => {}, duration: 400 }], + }, + ], + }); + + expect(result.errors.filter((e) => e.code === CODE)).toHaveLength(0); + }); + + it('does not warn for non-viewEnter triggers', () => { + const result = validateInteractConfig({ + interactions: [ + { + key: 'el', + trigger: 'click', + effects: [{ namedEffect: { type: 'FadeIn' }, duration: 400 }], + }, + ], + }); + + expect(result.errors.filter((e) => e.code === CODE)).toHaveLength(0); + }); + + it('does not warn for viewEnter repeat effects', () => { + const result = validateInteractConfig({ + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + effects: [ + { + key: 'other', + namedEffect: { type: 'FadeIn' }, + duration: 400, + triggerType: 'repeat', + }, + ], + }, + ], + }); + + expect(result.errors.filter((e) => e.code === CODE)).toHaveLength(0); + }); + }); +}); diff --git a/packages/interact-validate/test/validate.spec.ts b/packages/interact-validate/test/validate.spec.ts index e4bb4de7..1d0f0c9d 100644 --- a/packages/interact-validate/test/validate.spec.ts +++ b/packages/interact-validate/test/validate.spec.ts @@ -6,7 +6,7 @@ const VALID_CONFIG = { { key: 'el', trigger: 'viewEnter', - effects: [{ namedEffect: { type: 'FadeIn' }, duration: 400 }], + effects: [{ namedEffect: { type: 'FadeIn' }, duration: 400, fill: 'backwards' }], }, ], }; @@ -18,7 +18,7 @@ const CONFIG_WITH_WARNING = { { key: 'el', trigger: 'viewEnter', - effects: [{ namedEffect: { type: 'SlideIn' }, duration: 400 }], + effects: [{ namedEffect: { type: 'SlideIn' }, duration: 400, fill: 'backwards' }], }, ], }; diff --git a/packages/interact/docs/api/functions.md b/packages/interact/docs/api/functions.md index be44558d..64f82da3 100644 --- a/packages/interact/docs/api/functions.md +++ b/packages/interact/docs/api/functions.md @@ -247,7 +247,9 @@ The output covers every CSS-expressible aspect of the configuration: For entrance animations where the source and target are the same element, `generate()` emits an initial rule that hides the element until its animation starts. Inject the generated CSS into `` (preferred) or the beginning of ``. -The initial rule uses `:not([data-interact-enter])` so the element becomes visible once the animation begins. This only applies to `viewEnter` interactions with `triggerType: 'once'` (the default for `viewEnter`). +The initial rule uses `:not([data-interact-enter])` so the element becomes visible once the animation begins. This only applies to `viewEnter` interactions with `triggerType: 'once'` (the default for `viewEnter`) + +For any entrance animation, use `fill: 'backwards'` (or `'both'` when the final keyframe must persist). Entrance presets default to `backwards`. For `triggerType: 'repeat'`/`'alternate'`/`'state'`, manually apply the starting keyframe as inline styles on the target element and use `fill: 'both'`. @@ -256,10 +258,10 @@ For `triggerType: 'repeat'`/`'alternate'`/`'state'`, manually apply the starting ```css [data-interact-key='hero']:not([data-interact-enter]) { visibility: hidden; - transform: none; - translate: none; - scale: none; - rotate: none; + transform: none !important; + translate: none !important; + scale: none !important; + rotate: none !important; } ``` diff --git a/packages/interact/docs/examples/entrance-animations.md b/packages/interact/docs/examples/entrance-animations.md index 01aa40d9..721150a9 100644 --- a/packages/interact/docs/examples/entrance-animations.md +++ b/packages/interact/docs/examples/entrance-animations.md @@ -74,6 +74,7 @@ Add a delay for dramatic timing. }, duration: 1000, delay: 500, // Wait 500ms before starting + fill: 'backwards', // Hold first keyframe during delay easing: 'ease-out' }] } diff --git a/packages/interact/rules/full-lean.md b/packages/interact/rules/full-lean.md index 5942ed3d..5a3587fd 100644 --- a/packages/interact/rules/full-lean.md +++ b/packages/interact/rules/full-lean.md @@ -361,7 +361,7 @@ Each effect applies a visual change to a target element. An effect is either inl **`fill` guidance:** - `'both'` — use for scroll-driven (`viewProgress`), pointer-driven (`pointerMove`), and toggling effects (`hover`/`click` with `alternate`, `repeat`, or `state` type). -- `'backwards'` — use for entrance animations with `type: 'once'` when the element's own CSS already matches the final keyframe (applies the initial keyframe during any `delay`). +- `'backwards'` — use for entrance animations (`viewEnter` + `once`). Entrance presets default to `'backwards'`; set it explicitly for inline `keyframeEffect` entrances. Use `'both'` when the final keyframe must persist after the animation. **`composite`** — same as CSS's `animation-composition`. Controls how this effect combines with others on the same property (transforms & filters): diff --git a/packages/interact/rules/validate.md b/packages/interact/rules/validate.md index 794c0ac5..07d46944 100644 --- a/packages/interact/rules/validate.md +++ b/packages/interact/rules/validate.md @@ -233,6 +233,7 @@ Statically-detectable authoring pitfalls lifted from the trigger rule files. Eac | `EMPTY_STYLE_PROPERTIES` | A state effect's `transition.styleProperties` / `transitionProperties` is `[]` (toggles nothing). | `STATE_EFFECT` | | `STATE_REMOVE_WITHOUT_EFFECT_ID` | `stateAction: 'remove'` with no `effectId` to pair with a matching `'add'`. | `STATE_EFFECT` | | `RECOMMENDED_FILL_BOTH` | A scrubbed (`viewProgress`/`pointerMove`) or toggling (`alternate`/`repeat`/`state`) effect omits `fill: 'both'`. | `RECOMMENDED_FILL` | +| `RECOMMENDED_FILL_BACKWARDS` | A `viewEnter` + `once` named/keyframe effect targeting another element or using a same-element delay omits `fill: 'backwards'` or `'both'`. | `RECOMMENDED_FILL` | | `POINTER_AXIS_IGNORED` | `pointerMove` `params.axis` set on a `namedEffect`/`customEffect` (axis only applies to `keyframeEffect`). | `POINTER_AXIS` | | `KEYFRAME_PROP_NOT_CAMEL_CASE` | A `keyframeEffect` property name is kebab-case (not WAAPI camelCase). | `KEYFRAME_STYLE` | | `INVALID_INSET` | `viewEnter` `params.inset` is not 1–4 whitespace-separated CSS lengths/percentages. | `VIEW_INSET` | diff --git a/packages/interact/rules/viewenter.md b/packages/interact/rules/viewenter.md index eae35403..0b1c600b 100644 --- a/packages/interact/rules/viewenter.md +++ b/packages/interact/rules/viewenter.md @@ -23,6 +23,8 @@ This document contains rules for generating interactions that respond to element **Solution:** Call `generate(config, useFirstChild)` at build/generation time and embed the resulting CSS before first paint. Among all the CSS it produces, `generate()` includes initial rules that hide entrance-animated elements from the moment the page renders, before JS runs. The rules use `:not([data-interact-enter])` so elements become visible once the animation starts. +For entrance animations, set `fill: 'backwards'` (or `'both'` when the final keyframe must persist). Entrance presets default to `backwards`; inline `keyframeEffect` entrances should set it explicitly. + ### Generate CSS at build time and embed in HTML Prefer running this for the complete config in a **build/generation script** @@ -90,6 +92,7 @@ const css = generate(config, useFirstChild); // true for web; false for react/va - Inject runtime-generated CSS before the corresponding `Interact.create()` call and before revealing initially hidden content to minimize FOUC. - FOUC initial rules apply only to `viewEnter` + `triggerType: 'once'` (or no `triggerType`, which defaults to `'once'`) where source and target are the same element. +- For `viewEnter` + `once` entrance animations, use `fill: 'backwards'` (or `'both'` when the final keyframe must persist after the animation). - For `viewEnter` with `triggerType: 'repeat'`/`'alternate'`/`'state'`, manually apply the starting keyframe as inline styles on the target element and use `fill: 'both'`. - `generate(config)` processes all interactions in the config, not just `viewEnter`. @@ -152,7 +155,7 @@ Use `keyframeEffect` or `namedEffect` when the viewEnter should play an animatio - `[KEYFRAMES]` — array of keyframe objects (e.g. `[{ opacity: 0 }, { opacity: 1 }]`). Property names in camelCase. - `[EFFECT_NAME]` — unique string identifier for a `keyframeEffect`. - `[NAMED_EFFECT_DEFINITION]` — object with properties of pre-built effect from `@wix/motion-presets`. Refer to motion-presets rules for available presets and their options. -- `[FILL_MODE]` — `'both'` for `triggerType: 'alternate'`, `'repeat'`, or `'state'`. For `triggerType: 'once'`: use `'backwards'` when the animation's final keyframe has no additional effect (over element's base style); use `'both'` otherwise. +- `[FILL_MODE]` — `'both'` for `triggerType: 'alternate'`, `'repeat'`, or `'state'`. For `triggerType: 'once'`: use `'backwards'` for entrance, so the first keyframe applies during any `delay` (use `'both'` when the final keyframe must persist after the animation). - `[DURATION_MS]` — animation duration in milliseconds. - `[EASING_FUNCTION]` — CSS easing string or named easing from `@wix/motion`. - `[DELAY_MS]` — optional delay before the effect starts, in milliseconds. diff --git a/packages/interact/src/core/css.ts b/packages/interact/src/core/css.ts index aba27765..d5b1e10d 100644 --- a/packages/interact/src/core/css.ts +++ b/packages/interact/src/core/css.ts @@ -25,10 +25,10 @@ import { getCSSAnimation, MotionKeyframeEffect, TriggerVariant } from '@wix/moti export const DEFAULT_INITIAL = [ { name: 'visibility', value: 'hidden' }, - { name: 'transform', value: 'none' }, - { name: 'translate', value: 'none' }, - { name: 'scale', value: 'none' }, - { name: 'rotate', value: 'none' }, + { name: 'transform', value: 'none', important: true }, + { name: 'translate', value: 'none', important: true }, + { name: 'scale', value: 'none', important: true }, + { name: 'rotate', value: 'none', important: true }, ]; const LIST_ANIMATION_PROPERTY_NAMES = [ diff --git a/packages/interact/src/core/cssUtils.ts b/packages/interact/src/core/cssUtils.ts index 5abd8e27..6a9c28eb 100644 --- a/packages/interact/src/core/cssUtils.ts +++ b/packages/interact/src/core/cssUtils.ts @@ -145,7 +145,9 @@ export function CSSRuleToString(rule: CSSRuleData): string { selector = applySelectorCondition(selector, selectorCondition); } - const declarationsStr = declarations.map(({ name, value }) => `${name}: ${value};`).join('\n'); + const declarationsStr = declarations + .map(({ name, value, important }) => `${name}: ${value}${important ? ' !important' : ''};`) + .join('\n'); const cssRule = `${selector} {\n${declarationsStr}\n}`; return media ? `@media ${media} {\n${cssRule}\n}` : cssRule; diff --git a/packages/interact/src/types/css.ts b/packages/interact/src/types/css.ts index 433dd02d..7e7f05a9 100644 --- a/packages/interact/src/types/css.ts +++ b/packages/interact/src/types/css.ts @@ -19,7 +19,7 @@ export type ListCustomProps = { export type CSSRuleData = { key: string; childSelector?: string; - declarations: { name: string; value: string | number }[]; + declarations: { name: string; value: string | number; important?: boolean }[]; media?: string; states?: string[]; selectorCondition?: string; diff --git a/packages/interact/test/css.spec.ts b/packages/interact/test/css.spec.ts index 88c9ac3d..dc57ede2 100644 --- a/packages/interact/test/css.spec.ts +++ b/packages/interact/test/css.spec.ts @@ -440,10 +440,11 @@ describe('css._generate', () => { )!; expect(initialRule).toBeDefined(); - DEFAULT_INITIAL.forEach(({ name, value }) => { + DEFAULT_INITIAL.forEach(({ name, value, important }) => { const decl = initialRule.declarations.find((d) => d.name === name); expect(decl, `expected DEFAULT_INITIAL declaration: ${name}`).toBeDefined(); expect(decl!.value).toBe(value); + expect(decl!.important).toBe(important); }); const animationRule = cssRules.find( diff --git a/packages/interact/test/cssUtils.spec.ts b/packages/interact/test/cssUtils.spec.ts index 3a30b0c4..6c13107d 100644 --- a/packages/interact/test/cssUtils.spec.ts +++ b/packages/interact/test/cssUtils.spec.ts @@ -221,6 +221,20 @@ describe('CSSRuleToString', () => { expect(CSSRuleToString(rule)).toEqual(expected); }); + it('should serialize important declarations when flagged', () => { + const rule: CSSRuleData = { + key: 'my-el', + dataInteractEnterSelector: ':not([data-interact-enter])', + declarations: [ + { name: 'visibility', value: 'hidden', important: true }, + { name: 'transform', value: 'none', important: true }, + ], + }; + const expected = + '[data-interact-key="my-el"]:not([data-interact-enter]) {\nvisibility: hidden !important;\ntransform: none !important;\n}'; + expect(CSSRuleToString(rule)).toEqual(expected); + }); + it('should dataInteractEnterSelector when provided', () => { const rule: CSSRuleData = { key: 'my-el', diff --git a/packages/motion-presets/src/library/entrance/ArcIn.ts b/packages/motion-presets/src/library/entrance/ArcIn.ts index e6876044..1b0c1a46 100644 --- a/packages/motion-presets/src/library/entrance/ArcIn.ts +++ b/packages/motion-presets/src/library/entrance/ArcIn.ts @@ -1,5 +1,5 @@ import type { ArcIn, TimeAnimationOptions, EffectFourDirections, DomApi } from '../../types'; -import { toKeyframeValue, parseDirection, parseLength } from '../../utils'; +import { toKeyframeValue, parseDirection, parseLength, getEntranceFill } from '../../utils'; import { FOUR_DIRECTIONS } from '../../consts'; const ROTATION_ANGLE = 80; @@ -46,6 +46,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { return [ { ...options, + fill: getEntranceFill(options), name: fadeIn, duration: options.duration! * 0.7, easing: 'sineIn', @@ -54,6 +55,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { }, { ...options, + fill: getEntranceFill(options), name: arcIn, easing, custom, diff --git a/packages/motion-presets/src/library/entrance/BlurIn.ts b/packages/motion-presets/src/library/entrance/BlurIn.ts index d5df1228..1b1cbf55 100644 --- a/packages/motion-presets/src/library/entrance/BlurIn.ts +++ b/packages/motion-presets/src/library/entrance/BlurIn.ts @@ -1,5 +1,5 @@ import type { BlurIn, TimeAnimationOptions } from '../../types'; -import { toKeyframeValue } from '../../utils'; +import { toKeyframeValue, getEntranceFill } from '../../utils'; export function getNames(_: TimeAnimationOptions) { return ['motion-fadeIn', 'motion-blurIn']; @@ -23,6 +23,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { { ...options, name: fadeIn, + fill: getEntranceFill(options), duration: options.duration! * 0.7, easing: 'sineIn', custom: {}, @@ -31,6 +32,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { { ...options, name: blurIn, + fill: getEntranceFill(options), easing, composite: 'add' as const, // make sure we don't override existing filters on the component custom, diff --git a/packages/motion-presets/src/library/entrance/BounceIn.ts b/packages/motion-presets/src/library/entrance/BounceIn.ts index 4e10e063..562aef99 100644 --- a/packages/motion-presets/src/library/entrance/BounceIn.ts +++ b/packages/motion-presets/src/library/entrance/BounceIn.ts @@ -1,4 +1,10 @@ -import { getEasingFamily, getEasing, toKeyframeValue, parseDirection } from '../../utils'; +import { + getEasingFamily, + getEasing, + toKeyframeValue, + parseDirection, + getEntranceFill, +} from '../../utils'; import type { BounceIn, TimeAnimationOptions } from '../../types'; import { FOUR_DIRECTIONS } from '../../consts'; @@ -80,6 +86,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { { ...options, name: fadeIn, + fill: getEntranceFill(options), easing: 'quadOut', duration: (options.duration! * BOUNCE_KEYFRAMES[3].offset) / 100, custom: {}, @@ -88,6 +95,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { { ...options, name: bounceIn, + fill: getEntranceFill(options), easing: 'linear', custom, keyframes, diff --git a/packages/motion-presets/src/library/entrance/CurveIn.ts b/packages/motion-presets/src/library/entrance/CurveIn.ts index dc4bc8ff..33b94287 100644 --- a/packages/motion-presets/src/library/entrance/CurveIn.ts +++ b/packages/motion-presets/src/library/entrance/CurveIn.ts @@ -1,5 +1,5 @@ import type { CurveIn, TimeAnimationOptions, DomApi } from '../../types'; -import { toKeyframeValue, parseDirection, parseLength } from '../../utils'; +import { toKeyframeValue, parseDirection, parseLength, getEntranceFill } from '../../utils'; import { TWO_SIDES_DIRECTIONS } from '../../consts'; const DEFAULT_DEPTH = { value: 300, unit: 'px' }; @@ -45,6 +45,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { { ...options, name: curveIn, + fill: getEntranceFill(options), easing, custom, keyframes: [ @@ -67,6 +68,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { { ...options, name: fadeIn, + fill: getEntranceFill(options), easing, custom: {}, keyframes: [{ offset: 0, opacity: 0 }], diff --git a/packages/motion-presets/src/library/entrance/DropIn.ts b/packages/motion-presets/src/library/entrance/DropIn.ts index 13eb7a87..3218a1fd 100644 --- a/packages/motion-presets/src/library/entrance/DropIn.ts +++ b/packages/motion-presets/src/library/entrance/DropIn.ts @@ -1,5 +1,5 @@ import type { TimeAnimationOptions, DropIn } from '../../types'; -import { toKeyframeValue } from '../../utils'; +import { toKeyframeValue, getEntranceFill } from '../../utils'; export function getNames(_: TimeAnimationOptions) { return ['motion-fadeIn', 'motion-dropIn']; @@ -23,6 +23,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { { ...options, name: fadeIn, + fill: getEntranceFill(options), easing: 'quadOut', duration: options.duration! * 0.8, custom: {}, @@ -31,6 +32,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { { ...options, name: dropIn, + fill: getEntranceFill(options), easing, custom, keyframes: [ diff --git a/packages/motion-presets/src/library/entrance/ExpandIn.ts b/packages/motion-presets/src/library/entrance/ExpandIn.ts index 3a997ed1..073c97c8 100644 --- a/packages/motion-presets/src/library/entrance/ExpandIn.ts +++ b/packages/motion-presets/src/library/entrance/ExpandIn.ts @@ -1,5 +1,11 @@ import type { TimeAnimationOptions } from '../../types'; -import { getCssUnits, toKeyframeValue, parseLength, parseDirection } from '../../utils'; +import { + getCssUnits, + toKeyframeValue, + parseLength, + parseDirection, + getEntranceFill, +} from '../../utils'; import type { ExpandIn } from '../../types'; import { FOUR_DIRECTIONS } from '../../consts'; @@ -58,6 +64,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { easing, duration: options.duration! * 0.7, name: fadeIn, + fill: getEntranceFill(options), custom: {}, keyframes: [{ offset: 0, opacity: 0 }], }, @@ -65,6 +72,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { ...options, easing, name: expandIn, + fill: getEntranceFill(options), custom, keyframes: [ { diff --git a/packages/motion-presets/src/library/entrance/FadeIn.ts b/packages/motion-presets/src/library/entrance/FadeIn.ts index e4180504..4f25cd47 100644 --- a/packages/motion-presets/src/library/entrance/FadeIn.ts +++ b/packages/motion-presets/src/library/entrance/FadeIn.ts @@ -1,5 +1,7 @@ import { TimeAnimationOptions } from '../../types'; +import { getEntranceFill } from '../../utils'; + export function getNames(_: TimeAnimationOptions) { return ['motion-fadeIn']; } @@ -15,6 +17,7 @@ export function style(options: TimeAnimationOptions) { { ...options, name: fadeIn, + fill: getEntranceFill(options), easing: 'sineInOut', keyframes: [{ offset: 0, opacity: 0 }], }, diff --git a/packages/motion-presets/src/library/entrance/FlipIn.ts b/packages/motion-presets/src/library/entrance/FlipIn.ts index 955e7946..148f4adf 100644 --- a/packages/motion-presets/src/library/entrance/FlipIn.ts +++ b/packages/motion-presets/src/library/entrance/FlipIn.ts @@ -1,4 +1,4 @@ -import { parseDirection, toKeyframeValue } from '../../utils'; +import { parseDirection, toKeyframeValue, getEntranceFill } from '../../utils'; import type { EffectFourDirections, FlipIn, TimeAnimationOptions } from '../../types'; import { FOUR_DIRECTIONS } from '../../consts'; @@ -46,6 +46,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { ...options, easing: 'quadOut', name: fadeIn, + fill: getEntranceFill(options), custom: {}, keyframes: [{ offset: 0, opacity: 0 }], }, @@ -53,6 +54,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { ...options, easing, name: flipIn, + fill: getEntranceFill(options), custom, keyframes: [ { diff --git a/packages/motion-presets/src/library/entrance/FloatIn.ts b/packages/motion-presets/src/library/entrance/FloatIn.ts index ce74df8e..97d28514 100644 --- a/packages/motion-presets/src/library/entrance/FloatIn.ts +++ b/packages/motion-presets/src/library/entrance/FloatIn.ts @@ -1,5 +1,5 @@ import type { TimeAnimationOptions, FloatIn, EffectFourDirections } from '../../types'; -import { toKeyframeValue, parseDirection } from '../../utils'; +import { toKeyframeValue, parseDirection, getEntranceFill } from '../../utils'; import { FOUR_DIRECTIONS } from '../../consts'; const DEFAULT_DIRECTION: EffectFourDirections = 'left'; @@ -39,6 +39,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { { ...options, name: floatIn, + fill: getEntranceFill(options), easing, custom, keyframes: [ @@ -61,6 +62,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { { ...options, name: fadeIn, + fill: getEntranceFill(options), easing, custom: {}, keyframes: [{ offset: 0, opacity: 0 }], diff --git a/packages/motion-presets/src/library/entrance/FoldIn.ts b/packages/motion-presets/src/library/entrance/FoldIn.ts index c351b154..15a088d2 100644 --- a/packages/motion-presets/src/library/entrance/FoldIn.ts +++ b/packages/motion-presets/src/library/entrance/FoldIn.ts @@ -1,5 +1,5 @@ import type { EffectFourDirections, FoldIn, TimeAnimationOptions } from '../../types'; -import { parseDirection, toKeyframeValue } from '../../utils'; +import { parseDirection, toKeyframeValue, getEntranceFill } from '../../utils'; import { FOUR_DIRECTIONS } from '../../consts'; export function getNames(_: TimeAnimationOptions) { @@ -52,6 +52,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { ...options, easing: 'quadOut', name: fadeIn, + fill: getEntranceFill(options), custom: {}, keyframes: [{ offset: 0, opacity: 0 }], }, @@ -59,6 +60,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { ...options, easing, name: foldIn, + fill: getEntranceFill(options), custom, keyframes: [ { diff --git a/packages/motion-presets/src/library/entrance/GlideIn.ts b/packages/motion-presets/src/library/entrance/GlideIn.ts index 7efd98b5..719e528b 100644 --- a/packages/motion-presets/src/library/entrance/GlideIn.ts +++ b/packages/motion-presets/src/library/entrance/GlideIn.ts @@ -1,5 +1,11 @@ import type { TimeAnimationOptions, GlideIn } from '../../types'; -import { getCssUnits, toKeyframeValue, parseLength, parseDirection } from '../../utils'; +import { + getCssUnits, + toKeyframeValue, + parseLength, + parseDirection, + getEntranceFill, +} from '../../utils'; import { FOUR_DIRECTIONS } from '../../consts'; const DEFAULT_DIRECTION = 180; @@ -55,6 +61,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { { ...options, name: glideIn, + fill: getEntranceFill(options), easing, custom, keyframes: [ @@ -77,6 +84,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { { ...options, name: fadeIn, + fill: getEntranceFill(options), custom: {}, keyframes: [{ opacity: 0, offset: 0, easing: 'step-start' }], }, diff --git a/packages/motion-presets/src/library/entrance/RevealIn.ts b/packages/motion-presets/src/library/entrance/RevealIn.ts index a30a4437..9f23baaa 100644 --- a/packages/motion-presets/src/library/entrance/RevealIn.ts +++ b/packages/motion-presets/src/library/entrance/RevealIn.ts @@ -1,5 +1,5 @@ import type { RevealIn, TimeAnimationOptions, EffectFourDirections } from '../../types'; -import { getClipPolygonParams, parseDirection } from '../../utils'; +import { getClipPolygonParams, parseDirection, getEntranceFill } from '../../utils'; import { FOUR_DIRECTIONS } from '../../consts'; const DEFAULT_DIRECTION: EffectFourDirections = 'left'; @@ -30,6 +30,7 @@ export function style(options: TimeAnimationOptions) { ...options, easing, name: revealIn, + fill: getEntranceFill(options), custom, keyframes: [ { @@ -43,6 +44,7 @@ export function style(options: TimeAnimationOptions) { { ...options, name: fadeIn, + fill: getEntranceFill(options), easing, custom: {}, keyframes: [{ offset: 0, opacity: 0 }], diff --git a/packages/motion-presets/src/library/entrance/ShapeIn.ts b/packages/motion-presets/src/library/entrance/ShapeIn.ts index fb820841..677855d1 100644 --- a/packages/motion-presets/src/library/entrance/ShapeIn.ts +++ b/packages/motion-presets/src/library/entrance/ShapeIn.ts @@ -1,5 +1,5 @@ import type { Shape, ShapeIn, TimeAnimationOptions } from '../../types'; -import { toKeyframeValue } from '../../utils'; +import { toKeyframeValue, getEntranceFill } from '../../utils'; export function getNames(_: TimeAnimationOptions) { return ['motion-fadeIn', 'motion-shapeIn']; @@ -39,6 +39,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { { ...options, name: fadeIn, + fill: getEntranceFill(options), easing: 'quadOut', duration: options.duration! * 0.8, custom: {}, @@ -47,6 +48,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { { ...options, name: shapeIn, + fill: getEntranceFill(options), easing, custom, keyframes: [ diff --git a/packages/motion-presets/src/library/entrance/ShuttersIn.ts b/packages/motion-presets/src/library/entrance/ShuttersIn.ts index 55460f82..34a0ecb0 100644 --- a/packages/motion-presets/src/library/entrance/ShuttersIn.ts +++ b/packages/motion-presets/src/library/entrance/ShuttersIn.ts @@ -1,5 +1,11 @@ import { ShuttersIn, TimeAnimationOptions, EffectFourDirections } from '../../types'; -import { getShuttersClipPaths, getEasing, toKeyframeValue, parseDirection } from '../../utils'; +import { + getShuttersClipPaths, + getEasing, + toKeyframeValue, + parseDirection, + getEntranceFill, +} from '../../utils'; import { FOUR_DIRECTIONS } from '../../consts'; const DEFAULT_DIRECTION: EffectFourDirections = 'right'; @@ -32,6 +38,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { ...options, easing, name: shuttersIn, + fill: getEntranceFill(options), custom, keyframes: [ { @@ -45,6 +52,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { { ...options, name: fadeIn, + fill: getEntranceFill(options), custom: {}, keyframes: [{ opacity: 0, offset: 0, easing: 'step-start' }], }, diff --git a/packages/motion-presets/src/library/entrance/SlideIn.ts b/packages/motion-presets/src/library/entrance/SlideIn.ts index a593fd54..d037f4fa 100644 --- a/packages/motion-presets/src/library/entrance/SlideIn.ts +++ b/packages/motion-presets/src/library/entrance/SlideIn.ts @@ -1,5 +1,5 @@ import type { EffectFourDirections, SlideIn, TimeAnimationOptions } from '../../types'; -import { getClipPolygonParams, parseDirection } from '../../utils'; +import { getClipPolygonParams, parseDirection, getEntranceFill } from '../../utils'; import { FOUR_DIRECTIONS } from '../../consts'; const DEFAULT_DIRECTION: EffectFourDirections = 'left'; @@ -47,6 +47,7 @@ export function style(options: TimeAnimationOptions) { { ...options, name: slideIn, + fill: getEntranceFill(options), easing, custom, keyframes: [ @@ -63,6 +64,7 @@ export function style(options: TimeAnimationOptions) { { ...options, name: fadeIn, + fill: getEntranceFill(options), easing, custom: {}, keyframes: [{ offset: 0, opacity: 0 }], diff --git a/packages/motion-presets/src/library/entrance/SpinIn.ts b/packages/motion-presets/src/library/entrance/SpinIn.ts index 7c4b4e6d..749e5837 100644 --- a/packages/motion-presets/src/library/entrance/SpinIn.ts +++ b/packages/motion-presets/src/library/entrance/SpinIn.ts @@ -1,5 +1,5 @@ import type { SpinIn, TimeAnimationOptions } from '../../types'; -import { toKeyframeValue, parseDirection } from '../../utils'; +import { toKeyframeValue, parseDirection, getEntranceFill } from '../../utils'; import { SPIN_DIRECTIONS } from '../../consts'; const DEFAULT_DIRECTION: (typeof SPIN_DIRECTIONS)[number] = 'clockwise'; @@ -35,6 +35,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { { ...options, name: fadeIn, + fill: getEntranceFill(options), easing: 'cubicIn', duration: options.duration! * initialScale, custom: {}, @@ -43,6 +44,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { { ...options, name: spinIn, + fill: getEntranceFill(options), easing, custom, keyframes: [ diff --git a/packages/motion-presets/src/library/entrance/TiltIn.ts b/packages/motion-presets/src/library/entrance/TiltIn.ts index a6a13ecb..543c81f5 100644 --- a/packages/motion-presets/src/library/entrance/TiltIn.ts +++ b/packages/motion-presets/src/library/entrance/TiltIn.ts @@ -1,4 +1,10 @@ -import { getClipPolygonParams, toKeyframeValue, parseDirection, parseLength } from '../../utils'; +import { + getClipPolygonParams, + toKeyframeValue, + parseDirection, + parseLength, + getEntranceFill, +} from '../../utils'; import type { TiltIn, TimeAnimationOptions, EffectTwoSides } from '../../types'; import { TWO_SIDES_DIRECTIONS } from '../../consts'; @@ -46,6 +52,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { { ...options, name: fadeIn, + fill: getEntranceFill(options), duration: options.duration! * 0.2, easing: 'cubicOut', custom: {}, @@ -54,6 +61,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { { ...options, name: tiltInRotate, + fill: getEntranceFill(options), easing, custom: rotateCustom, keyframes: [ @@ -68,6 +76,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { { ...options, name: tiltInClip, + fill: getEntranceFill(options), easing, composite: 'add' as const, duration: options.duration! * 0.8, diff --git a/packages/motion-presets/src/library/entrance/TurnIn.ts b/packages/motion-presets/src/library/entrance/TurnIn.ts index 10683750..8d5f679a 100644 --- a/packages/motion-presets/src/library/entrance/TurnIn.ts +++ b/packages/motion-presets/src/library/entrance/TurnIn.ts @@ -1,5 +1,5 @@ import type { TurnIn, TimeAnimationOptions, EffectFourCorners } from '../../types'; -import { toKeyframeValue, parseDirection } from '../../utils'; +import { toKeyframeValue, parseDirection, getEntranceFill } from '../../utils'; import { FOUR_CORNERS_DIRECTIONS } from '../../consts'; const DEFAULT_DIRECTION: EffectFourCorners = 'top-left'; @@ -46,6 +46,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { { ...options, name: fadeIn, + fill: getEntranceFill(options), duration: options.duration! * 0.6, easing: 'sineIn', custom: {}, @@ -54,6 +55,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { { ...options, name: turnIn, + fill: getEntranceFill(options), easing, custom, keyframes: [ diff --git a/packages/motion-presets/src/library/entrance/WinkIn.ts b/packages/motion-presets/src/library/entrance/WinkIn.ts index 3f2fab66..4ad4dfd1 100644 --- a/packages/motion-presets/src/library/entrance/WinkIn.ts +++ b/packages/motion-presets/src/library/entrance/WinkIn.ts @@ -1,4 +1,4 @@ -import { getClipPolygonParams, parseDirection } from '../../utils'; +import { getClipPolygonParams, parseDirection, getEntranceFill } from '../../utils'; import type { TimeAnimationOptions, WinkIn } from '../../types'; import { AXIS_DIRECTIONS } from '../../consts'; @@ -40,6 +40,7 @@ export function style(options: TimeAnimationOptions) { ...options, easing: 'quadOut', name: fadeIn, + fill: getEntranceFill(options), custom: {}, keyframes: [{ offset: 0, opacity: 0 }], }, @@ -47,6 +48,7 @@ export function style(options: TimeAnimationOptions) { ...options, easing, name: winkInClip, + fill: getEntranceFill(options), custom, keyframes: [ { @@ -62,6 +64,7 @@ export function style(options: TimeAnimationOptions) { duration: options.duration! * 0.85, easing, name: winkInRotate, + fill: getEntranceFill(options), custom, keyframes: [ { diff --git a/packages/motion-presets/src/library/entrance/test/entranceFill.spec.ts b/packages/motion-presets/src/library/entrance/test/entranceFill.spec.ts new file mode 100644 index 00000000..d77eb974 --- /dev/null +++ b/packages/motion-presets/src/library/entrance/test/entranceFill.spec.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from 'vitest'; + +import * as entrancePresets from '../index'; +import { baseMockOptions } from './testUtils'; +import type { TimeAnimationOptions } from '../../../types'; + +const presetEntries = Object.entries(entrancePresets) as [ + string, + { style: (options: TimeAnimationOptions) => unknown[] }, +][]; + +describe('entrance presets fill defaults', () => { + test.each(presetEntries)( + '%s defaults every animation descriptor to backwards fill', + (_name, preset) => { + const mockOptions = { + ...baseMockOptions, + duration: 500, + namedEffect: { type: _name }, + } as TimeAnimationOptions; + + const result = preset.style(mockOptions); + + expect(result.length).toBeGreaterThan(0); + result.forEach((animation) => { + expect((animation as { fill?: string }).fill).toBe('backwards'); + }); + }, + ); + + test.each(presetEntries)('%s preserves an explicit fill override', (_name, preset) => { + const mockOptions = { + ...baseMockOptions, + duration: 500, + fill: 'forwards', + namedEffect: { type: _name }, + } as TimeAnimationOptions; + + const result = preset.style(mockOptions); + + result.forEach((animation) => { + expect((animation as { fill?: string }).fill).toBe('forwards'); + }); + }); +}); diff --git a/packages/motion-presets/src/utils.ts b/packages/motion-presets/src/utils.ts index 852ad1d7..85598d23 100644 --- a/packages/motion-presets/src/utils.ts +++ b/packages/motion-presets/src/utils.ts @@ -4,6 +4,8 @@ import type { EffectScrollRange, Point, ScrubTransitionEasing, + AnimationFillMode, + TimeAnimationOptions, } from '@wix/motion'; export type Direction = @@ -203,6 +205,10 @@ export function deg2rad(angleInDeg: number): number { return (angleInDeg * Math.PI) / 180; } +export function getEntranceFill(options: TimeAnimationOptions): AnimationFillMode { + return options.fill ?? 'backwards'; +} + export function getTransformParams( originDirection: { dx: number; dy: number }, angleInRad: number, diff --git a/packages/motion/src/api/cssAnimations.ts b/packages/motion/src/api/cssAnimations.ts index edc94cb1..4d61acf0 100644 --- a/packages/motion/src/api/cssAnimations.ts +++ b/packages/motion/src/api/cssAnimations.ts @@ -25,7 +25,7 @@ function getAnimationAsCSS( const isAutoDuration = duration === 'auto'; return `${animationName} ${isAutoDuration ? 'auto' : `${duration}ms`}${ - isAutoDuration ? ' ' : ` ${delay || 1}ms ` + isAutoDuration ? ' ' : ` ${delay ?? 0}ms ` }${easing}${fill && fill !== 'none' ? ` ${fill}` : ''} ${ !iterations || iterations === Infinity ? 'infinite' : iterations }${direction === 'normal' ? '' : ` ${direction}`} ${isRunning ? '' : 'paused'}`; diff --git a/packages/motion/test/motion.spec.ts b/packages/motion/test/motion.spec.ts index 698096f0..50139818 100644 --- a/packages/motion/test/motion.spec.ts +++ b/packages/motion/test/motion.spec.ts @@ -315,12 +315,37 @@ describe('motion.ts', () => { expect(result[0]).toMatchObject({ target: '#test-target', - animation: 'fade-in 1000ms 1ms ease-in forwards 2 paused', + animation: 'fade-in 1000ms 0ms ease-in forwards 2 paused', name: 'fade-in', keyframes: [{ opacity: 0 }, { opacity: 1 }], }); }); + test('should preserve explicit zero delay in generated CSS animation shorthand', () => { + const animationOptions: AnimationOptions = { + namedEffect: { type: 'FadeIn', id: 'fade' }, + duration: 1000, + delay: 0, + }; + + const result = getCSSAnimation('test-target', animationOptions); + + expect(result[0].animation).toContain('0ms'); + expect(result[0].animation).not.toContain('1ms'); + }); + + test('should preserve positive delay in generated CSS animation shorthand', () => { + const animationOptions: AnimationOptions = { + namedEffect: { type: 'FadeIn', id: 'fade' }, + duration: 1000, + delay: 200, + }; + + const result = getCSSAnimation('test-target', animationOptions); + + expect(result[0].animation).toContain('200ms'); + }); + test('should handle named effects', () => { const animationOptions: AnimationOptions = { namedEffect: { diff --git a/skills/interactor/SKILL.md b/skills/interactor/SKILL.md index 012ae4ca..4251a271 100644 --- a/skills/interactor/SKILL.md +++ b/skills/interactor/SKILL.md @@ -244,9 +244,11 @@ animation no-ops. Apply them every time, even if you don't open a reference file 3. **FOUC prevention.** Follow the canonical CSS generation policy in `references/integration-recipes.md`. For the generated initial-rule behavior and trigger-specific exceptions, see “CSS generation & FOUC” in - `references/config-schema.md`. For `viewEnter` + `once` where source ≠ target, - `generate()` emits no hiding rules — set `fill: 'backwards'` on the effect so - targets don't flash before the trigger. + `references/config-schema.md`. Same-element `viewEnter` + `once` entrances get + author-important neutral initial rules from `generate()`. Always set + `fill: 'backwards'` on `viewEnter` + `once` animation effects (or `'both'` + when the final keyframe must persist) so delayed entrances hold their first + keyframe after the entrance marker is set. 4. **Vanilla binding.** You must then call the **standalone** `add(element, 'key')` for each element once it exists in the DOM. For clean up call the `remove('key')` function. diff --git a/skills/interactor/references/config-schema.md b/skills/interactor/references/config-schema.md index 5d83ea15..8db1c478 100644 --- a/skills/interactor/references/config-schema.md +++ b/skills/interactor/references/config-schema.md @@ -122,8 +122,10 @@ referenced entry and may override any of them (`key`, `duration`, `easing`, ``` **`fill` guidance:** use `'both'` for scroll/pointer-driven and for toggling -hover/click (`alternate`/`repeat`/`state`). Use `'backwards'` for `viewEnter` + -`once` entrances when source ≠ target (see [CSS generation & FOUC](#css-generation--fouc)). +hover/click (`alternate`/`repeat`/`state`). Entrance presets default to +`'backwards'`. Set `fill: 'backwards'` explicitly for `viewEnter` + `once` +`keyframeEffect` entrances. Use `'both'` when the final keyframe must persist +after the animation (see [CSS generation & FOUC](#css-generation--fouc)). **`composite`:** `'replace'` (default) overwrites prior values; `'add'` concatenates transform/filter functions; `'accumulate'` sums matching function args @@ -360,12 +362,14 @@ selectors target `:first-child`; `false` for **vanilla** and **React**. The defa is `true`, so vanilla/React callers must pass `false` explicitly. **FOUC prevention (viewEnter + once):** For entrance animations where source and -target are the **same** element, `generate()` emits initial rules that hide the -target until its animation starts (gated by `:not([data-interact-enter])`). When -source ≠ target, `generate()` emits **no** hiding rules for the targets — so set -`fill: 'backwards'` on the effect to prevent FOUC. This matters most with motion-presets -`namedEffect`s. For `repeat`/`alternate`/`state`, inline the -starting keyframe and use `fill: 'both'`. `viewProgress` needs no FOUC rules. +target are the **same** element, `generate()` emits author-important initial rules +that hide the target and neutralize transforms until its animation starts (gated +by `:not([data-interact-enter])`). When source ≠ target, `generate()` emits **no** +hiding rules for the targets. In both cases, set `fill: 'backwards'` on every +`viewEnter` + `once` animation effect so any `delay` holds the first keyframe +after the entrance marker is set. Use `'both'` when the final keyframe must +persist. Entrance presets default to `backwards`. For `repeat`/`alternate`/`state`, +inline the starting keyframe and use `fill: 'both'`. `viewProgress` needs no FOUC rules. For when and where to emit this CSS, follow the canonical static/pre-rendered policy in `references/integration-recipes.md`. diff --git a/skills/interactor/references/triggers.md b/skills/interactor/references/triggers.md index 2a8e3a98..0acb5728 100644 --- a/skills/interactor/references/triggers.md +++ b/skills/interactor/references/triggers.md @@ -45,9 +45,10 @@ use **separate** source and target elements (trigger on a stable wrapper, animat child via `selector`, or point the effect at a different `key`). **FOUC:** `once` entrances need injected `generate()` CSS before first paint (see -`config-schema.md` and SKILL.md invariant 3). When source ≠ target (staggering -children via `selector`) `generate()` emits no hiding rules for those targets — also -set `fill: 'backwards'` on the effect so they don't flash before the trigger. +`config-schema.md` and SKILL.md invariant 3). Set `fill: 'backwards'` on every +`viewEnter` + `once` animation effect so any `delay` holds the first keyframe. +When source ≠ target (staggering children via `selector`) `generate()` emits no +hiding rules for those targets — `fill: 'backwards'` is still required. ```ts { interactions: [{ key: 'hero', trigger: 'viewEnter', params: { threshold: 0.2 }, diff --git a/skills/interactor/references/validate.md b/skills/interactor/references/validate.md index bf85c9aa..7d82c7bf 100644 --- a/skills/interactor/references/validate.md +++ b/skills/interactor/references/validate.md @@ -108,7 +108,7 @@ Keep applying the semantic checklist in SKILL.md and trigger/preset references f - **Preset registry** — whether `namedEffect.type` is a registered preset or has valid options - **DOM / markup** — element existence for keys/selectors, matching `data-interact-key` / `interactKey` - **`registerEffects()` order** — unregistered presets log a warning, not a validation error -- **FOUC / `generate()`** — CSS injection, `useFirstChild` parity +- **FOUC / `generate()`** — CSS injection, `useFirstChild` parity (validator also emits `RECOMMENDED_FILL_BACKWARDS` when a `viewEnter` + `once` named/keyframe effect targeting another element or using a same-element delay omits `backwards`/`both`) - **`overflow: clip`** — ancestors with `overflow: hidden` break `viewProgress` ---