diff --git a/packages/interact-validate/README.md b/packages/interact-validate/README.md index 9ebd4fe1..b14d0d7c 100644 --- a/packages/interact-validate/README.md +++ b/packages/interact-validate/README.md @@ -125,7 +125,7 @@ const ExperienceSchema = z.object({ }); ``` -> `InteractConfigSchema` carries a `.transform()`, so a successful `.parse()` returns the config augmented with an internal `warnings` array (`validateInteractConfig` consumes that for you). `customEffect` and function-valued `offsetEasing` are accepted as opaque functions and not deep-validated. Interactions and effects also accept `$`-prefixed plugin fields (e.g. `$splitText`) — config routed to `Interact.use()` plugins. Their schemas use `.catchall(z.unknown())` + a key check rather than `.strict()`: `$`-prefixed fields are accepted with opaque values, while any non-prefixed unknown key is still rejected as `SCHEMA_UNRECOGNIZED_KEYS`. +> `InteractConfigSchema` carries a `.transform()`, so a successful `.parse()` returns the config augmented with an internal `warnings` array (`validateInteractConfig` consumes that for you). `customEffect` and function-valued `offsetEasing` are accepted as opaque functions and not deep-validated (a function `offsetEasing` does raise the `FUNCTION_OFFSET_EASING` warning, since `generate()` cannot compile it to CSS). Interactions and effects also accept `$`-prefixed plugin fields (e.g. `$splitText`) — config routed to `Interact.use()` plugins. Their schemas use `.catchall(z.unknown())` + a key check rather than `.strict()`: `$`-prefixed fields are accepted with opaque values, while any non-prefixed unknown key is still rejected as `SCHEMA_UNRECOGNIZED_KEYS`. ## Severity model @@ -149,6 +149,7 @@ Every issue is `'error'` or `'warning'`. `valid` is `true` **iff** no `'error'` | `POINTER_AXIS` | `POINTER_AXIS_IGNORED` | warning | | `CSS_PROPERTY_NAME` | `INVALID_CSS_PROPERTY_NAME` | warning | | `VIEW_INSET` | `INVALID_INSET` | warning | +| `OFFSET_EASING` | `FUNCTION_OFFSET_EASING` | warning | Set a category to `'off'` to drop those issues, or `'warning'` / `'error'` to set their severity. **All other codes** (every `SCHEMA_*`, numeric, effect-source, and referential code) are not in a category and **cannot** be silenced or re-leveled via `severityOverrides` — they always emit at their built-in severity. Precedence: `'off'` first (drops the issue), then a `'warning'`/`'error'` override, then `strict` (forces the rest to `'error'`). @@ -214,6 +215,7 @@ These encode statically-detectable authoring pitfalls from the trigger rule file | `POINTER_AXIS_IGNORED` | `pointerMove` `params.axis` set on a `namedEffect`/`customEffect` (axis only applies to `keyframeEffect`). | `POINTER_AXIS` | | `INVALID_CSS_PROPERTY_NAME` | A keyframe or state-effect property name is neither camelCase nor kebab-case (both are accepted). | `CSS_PROPERTY_NAME` | | `INVALID_INSET` | `viewEnter` `params.inset` is not 1–4 CSS lengths/percentages. | `VIEW_INSET` | +| `FUNCTION_OFFSET_EASING` | A sequence's `offsetEasing` is a function, so `generate()` omits that sequence from the generated CSS. | `OFFSET_EASING` | ## Usage recipes diff --git a/packages/interact-validate/src/errors.ts b/packages/interact-validate/src/errors.ts index ea5a6a7b..2b144003 100644 --- a/packages/interact-validate/src/errors.ts +++ b/packages/interact-validate/src/errors.ts @@ -33,6 +33,7 @@ const RULE_CODE_MAP: Record = { POINTER_AXIS_IGNORED: 'POINTER_AXIS', INVALID_CSS_PROPERTY_NAME: 'CSS_PROPERTY_NAME', INVALID_INSET: 'VIEW_INSET', + FUNCTION_OFFSET_EASING: 'OFFSET_EASING', }; export function finalize( diff --git a/packages/interact-validate/src/semantic/collectSemanticWarnings.ts b/packages/interact-validate/src/semantic/collectSemanticWarnings.ts index c9c6986d..f969c94d 100644 --- a/packages/interact-validate/src/semantic/collectSemanticWarnings.ts +++ b/packages/interact-validate/src/semantic/collectSemanticWarnings.ts @@ -4,6 +4,7 @@ // `collectSemanticWarnings` (consumed by the schema `transform`). import type { Path, SemanticIssue, AnyConfig, Visitors } from '../types'; +import { checkFunctionOffsetEasing } from './cssGeneration'; import { checkCSSPropertyNames, checkInvalidInset } from './cssSyntax'; import { checkSameElementRetrigger, checkHitAreaShift } from './fouc'; import { @@ -86,6 +87,7 @@ export function collectSemanticWarnings(config: AnyConfig): SemanticIssue[] { ? { ...((config.sequences ?? {})[sequenceId] ?? {}), ...sequence } : sequence; warnings.push(...checkSameElementRetrigger(path, resolvedSequence, owner)); + warnings.push(...checkFunctionOffsetEasing(path, sequence)); }, }); diff --git a/packages/interact-validate/src/semantic/cssGeneration.ts b/packages/interact-validate/src/semantic/cssGeneration.ts new file mode 100644 index 00000000..b46aeee3 --- /dev/null +++ b/packages/interact-validate/src/semantic/cssGeneration.ts @@ -0,0 +1,20 @@ +import type { Path, SemanticIssue, AnySequence } from '../types'; + +// `generate()` compiles a sequence's stagger into a `calc()` delay driven by +// `--motion--index` custom properties, so `offsetEasing` has to be a string it can +// turn into CSS math. A `(p: number) => number` function has no CSS equivalent, and the whole +// sequence is dropped from the generated CSS — it still animates once Interact initializes, but +// nothing is pre-rendered, so a `viewEnter` sequence loses its FOUC-prevention rules. +export function checkFunctionOffsetEasing(path: Path, sequence: AnySequence): SemanticIssue[] { + if (typeof sequence.offsetEasing !== 'function') return []; + + return [ + { + code: 'custom', + params: { domainCode: 'FUNCTION_OFFSET_EASING' }, + path: [...path, 'offsetEasing'], + message: + 'A function `offsetEasing` cannot be expressed in CSS, so `generate()` omits this sequence from the generated CSS (an entrance sequence loses FOUC prevention). Use a named easing, `cubic-bezier(...)`, or `linear(...)`.', + }, + ]; +} diff --git a/packages/interact-validate/src/types.ts b/packages/interact-validate/src/types.ts index 2d9c6140..6921d0dd 100644 --- a/packages/interact-validate/src/types.ts +++ b/packages/interact-validate/src/types.ts @@ -53,6 +53,7 @@ export type AnyEffect = { export type AnySequence = { triggerType?: string; sequenceId?: string; + offsetEasing?: string | ((...args: unknown[]) => unknown); effects?: AnyEffect[]; conditions?: string[]; }; diff --git a/packages/interact-validate/test/rules/offsetEasing.spec.ts b/packages/interact-validate/test/rules/offsetEasing.spec.ts new file mode 100644 index 00000000..c43a2252 --- /dev/null +++ b/packages/interact-validate/test/rules/offsetEasing.spec.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest'; +import { validateInteractConfig } from '../../src'; + +const EFFECTS = [{ namedEffect: { type: 'FadeIn' }, duration: 400 }]; + +describe('offsetEasing', () => { + describe('FUNCTION_OFFSET_EASING (warning)', () => { + it('warns on an inline sequence with a function offsetEasing', () => { + const result = validateInteractConfig({ + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + sequences: [{ offset: 100, offsetEasing: (p: number) => p ** 2, effects: EFFECTS }], + }, + ], + }); + const err = result.errors.find((e) => e.code === 'FUNCTION_OFFSET_EASING'); + + expect(err).toBeDefined(); + expect(err?.severity).toBe('warning'); + expect(err?.path).toEqual(['interactions', 0, 'sequences', 0, 'offsetEasing']); + expect(result.valid).toBe(true); + }); + + it('warns once, at the definition, for a referenced registry sequence', () => { + const result = validateInteractConfig({ + sequences: { + stagger: { offset: 100, offsetEasing: (p: number) => p ** 2, effects: EFFECTS }, + }, + interactions: [ + { key: 'el', trigger: 'viewEnter', sequences: [{ sequenceId: 'stagger' }] }, + { key: 'el2', trigger: 'viewEnter', sequences: [{ sequenceId: 'stagger' }] }, + ], + }); + const errs = result.errors.filter((e) => e.code === 'FUNCTION_OFFSET_EASING'); + + expect(errs).toHaveLength(1); + expect(errs[0].path).toEqual(['sequences', 'stagger', 'offsetEasing']); + }); + + it('warns when a reference overrides a string easing with a function', () => { + const result = validateInteractConfig({ + sequences: { stagger: { offset: 100, offsetEasing: 'quadIn', effects: EFFECTS } }, + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + sequences: [{ sequenceId: 'stagger', offsetEasing: (p: number) => p ** 2 }], + }, + ], + }); + const errs = result.errors.filter((e) => e.code === 'FUNCTION_OFFSET_EASING'); + + expect(errs).toHaveLength(1); + expect(errs[0].path).toEqual(['interactions', 0, 'sequences', 0, 'offsetEasing']); + }); + + it.each(['linear', 'quadIn', 'cubic-bezier(0.25, 0.1, 0.25, 1)', 'linear(0, 0.5 50%, 1)'])( + 'does not warn for the string easing %s', + (offsetEasing) => { + const result = validateInteractConfig({ + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + sequences: [{ offset: 100, offsetEasing, effects: EFFECTS }], + }, + ], + }); + + expect(result.errors.filter((e) => e.code === 'FUNCTION_OFFSET_EASING')).toHaveLength(0); + }, + ); + + it('does not warn when offsetEasing is omitted', () => { + const result = validateInteractConfig({ + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + sequences: [{ offset: 100, effects: EFFECTS }], + }, + ], + }); + + expect(result.errors.filter((e) => e.code === 'FUNCTION_OFFSET_EASING')).toHaveLength(0); + }); + }); + + describe('OFFSET_EASING rule category', () => { + const config = { + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + sequences: [{ offset: 100, offsetEasing: (p: number) => p ** 2, effects: EFFECTS }], + }, + ], + }; + + it('can be silenced via severityOverrides', () => { + const result = validateInteractConfig(config, { + severityOverrides: { OFFSET_EASING: 'off' }, + }); + + expect(result.errors.filter((e) => e.code === 'FUNCTION_OFFSET_EASING')).toHaveLength(0); + }); + + it('can be demoted to info', () => { + const result = validateInteractConfig(config, { + severityOverrides: { OFFSET_EASING: 'info' }, + }); + + expect(result.errors.find((e) => e.code === 'FUNCTION_OFFSET_EASING')?.severity).toBe('info'); + }); + + it('is promoted to an error by strict', () => { + const result = validateInteractConfig(config, { strict: true }); + + expect(result.errors.find((e) => e.code === 'FUNCTION_OFFSET_EASING')?.severity).toBe( + 'error', + ); + expect(result.valid).toBe(false); + }); + }); +}); diff --git a/packages/interact/docs/api/types.md b/packages/interact/docs/api/types.md index c05e3807..728d71ed 100644 --- a/packages/interact/docs/api/types.md +++ b/packages/interact/docs/api/types.md @@ -772,8 +772,8 @@ type SequenceOptionsConfig = { - `delay` - Base delay (ms) applied to all effects in the sequence. Default: `0`. - `offset` - Stagger interval (ms) between consecutive effects. Default: `0`. -- `offsetEasing` - Easing function or named string for offset distribution (`'linear'`, `'quadIn'`, `'sineOut'`, etc.). Default: `linear`. -- `sequenceId` - Optional ID for referencing a reusable sequence from `InteractConfig.sequences`. +- `offsetEasing` - Easing function or named string for offset distribution (`'linear'`, `'quadIn'`, `'sineOut'`, etc.). Default: `linear`. Only string easings can be compiled into generated CSS — a function excludes the sequence from `generate()`'s output. See [Stagger in Generated CSS](../guides/sequences.md#stagger-in-generated-css). +- `sequenceId` - Optional ID for referencing a reusable sequence from `InteractConfig.sequences`. Also names the `--motion--index` custom properties that carry the stagger in generated CSS. Defaults to `seq--`, derived from the config position so CSS generation and the runtime agree. - `conditions` - Optional array of condition IDs. When set, the sequence is only active when all conditions match. - `triggerType` - Controls play behavior for event trigger sequences (`hover`, `click`, `activate`, `interest`, `viewEnter`). Same values as `TimeEffect.triggerType`: `'once'` (default for viewEnter), `'alternate'` (default for hover/click), `'repeat'`, `'state'`. diff --git a/packages/interact/docs/examples/list-patterns.md b/packages/interact/docs/examples/list-patterns.md index 34d2b0c0..b2a06242 100644 --- a/packages/interact/docs/examples/list-patterns.md +++ b/packages/interact/docs/examples/list-patterns.md @@ -904,6 +904,10 @@ Different `offsetEasing` values produce distinct stagger patterns: { offset: 80, offsetEasing: 'sineOut' } ``` +Any string easing works — named keys, `cubic-bezier(...)`, or `linear(...)`. Keep it a string rather than a +function if the list should also be staggered by generated CSS; see +[Stagger in Generated CSS](../guides/sequences.md#stagger-in-generated-css). + ### 20. Reusable Sequences with `sequenceId` Define a sequence once, reference it from multiple interactions: diff --git a/packages/interact/docs/guides/sequences.md b/packages/interact/docs/guides/sequences.md index 2c22ddee..4f210c89 100644 --- a/packages/interact/docs/guides/sequences.md +++ b/packages/interact/docs/guides/sequences.md @@ -18,6 +18,35 @@ For example, with 5 effects and `offset: 200`: | `quadIn` | 0, 50, 200, 450, 800 | Slow start, then rapid | | `sineOut` | 0, 306, 565, 739, 800 | Fast start, then gradual | +## Stagger in Generated CSS + +`generate()` emits **one** animation rule per sequence effect, not one per list item — the item count +isn't known when the CSS is produced. The per-item delay is therefore expressed as a `calc()` over two +custom properties naming the element's position in the sequence: + +```css +animation: card-entrance 600ms + calc((0 + * 80 * var(--motion-card-stagger-last, 1)) * 1ms) …; +``` + +At runtime the `Sequence` writes `--motion--index` and `--motion--last` onto each +target element, and the shared rule resolves to a different delay per item. Before that — during SSR and +until Interact initializes — the `var()` fallbacks resolve `index` to `0`, so every element sits at the +base delay and the CSS stays valid and FOUC-free. + +This is why sequences need a stable `sequenceId`: it names the custom properties, and the CSS half and the +runtime half must agree on it. When you don't provide one, Interact derives it from the sequence's position +in the config (`seq--`) so both halves compute the same value from the same +config. + +> **`offsetEasing` must be a string for CSS generation.** A `(p: number) => number` function has no CSS +> equivalent, so `generate()` skips the entire sequence — the animations still run once Interact +> initializes, but nothing is rendered ahead of time and entrance animations may flash. Use a named easing, +> `cubic-bezier(...)`, or `linear(...)` for anything that needs generated CSS. +> +> [`@wix/interact-validate`](https://github.com/wix/interact/blob/master/packages/interact-validate/README.md) +> reports this statically as the `FUNCTION_OFFSET_EASING` warning. + ## Config Structure Sequences can be defined at two levels: @@ -124,11 +153,15 @@ type SequenceOptionsConfig = { delay?: number; // Base delay (ms). Default: 0 offset?: number; // Stagger interval (ms). Default: 0 offsetEasing?: string | ((p: number) => number); // Easing for offset distribution - sequenceId?: string; // ID for reusable sequence reference + sequenceId?: string; // ID for reusable sequence reference, and for the CSS stagger custom properties conditions?: string[]; // Media query condition IDs }; ``` +A function `offsetEasing` works at runtime but excludes the sequence from +[generated CSS](#stagger-in-generated-css). Auto-generated `sequenceId`s are derived from the config +position, so `generate()` and the runtime agree on them. + ### `SequenceConfig` Inline sequence definition (extends `SequenceOptionsConfig`): diff --git a/packages/interact/rules/full-lean.md b/packages/interact/rules/full-lean.md index 8367f102..aaf83c2e 100644 --- a/packages/interact/rules/full-lean.md +++ b/packages/interact/rules/full-lean.md @@ -528,8 +528,10 @@ Coordinate multiple effects with staggered timing. Prefer sequences over manual effects: (Effect | EffectRef)[]; // REQUIRED delay?: number; // ms before sequence starts offset?: number; // ms between each child's animation start - offsetEasing?: string; // easing curve for staggering offsets - sequenceId?: string; // for caching/referencing + offsetEasing?: string; // easing curve for staggering offsets - keep it a string, a + // function excludes the sequence from generated CSS + sequenceId?: string; // for caching/referencing; also names the CSS stagger custom + // properties. Defaults to `seq--` conditions?: string[]; // ids referencing the top-level conditions map } ``` diff --git a/packages/interact/rules/integration.md b/packages/interact/rules/integration.md index 664bb754..55562771 100644 --- a/packages/interact/rules/integration.md +++ b/packages/interact/rules/integration.md @@ -269,6 +269,15 @@ Define reusable sequences in `InteractConfig.sequences` and reference by `sequen } ``` +- **MUST** use a **string** `offsetEasing` (named key, `cubic-bezier(...)` or `linear(...)`) on any + sequence that needs generated CSS. `generate()` compiles the stagger into a `calc()` delay driven by + `--motion--index` custom properties, which a `(p: number) => number` function cannot + express — such a sequence is omitted from the generated CSS entirely and loses FOUC prevention. + `@wix/interact-validate` flags it as `FUNCTION_OFFSET_EASING` (warning, rule category `OFFSET_EASING`). +- **Rule**: `sequenceId` names those custom properties, so it must be identical on both sides. + Interact handles this for you — omitted ids default to `seq--`, derived + from the config position — but a hand-written id must be stable across CSS generation and runtime. + --- ## CSS Generation & FOUC Prevention diff --git a/packages/interact/rules/validate.md b/packages/interact/rules/validate.md index 0b0e6ab7..cab2129d 100644 --- a/packages/interact/rules/validate.md +++ b/packages/interact/rules/validate.md @@ -56,7 +56,7 @@ if (!result.valid) { - `valid` is `true` when no remaining issue has severity `'error'`. **Warnings alone do not make `valid: false`.** - `errors` holds **all** surfaced issues — both `'error'` and `'warning'` severities. Filter on `severity` to separate them. - Issues are sorted lexicographically by `path`. -- Validation runs in two layers: a **structural** zod parse first (produces `SCHEMA_*` and numeric/threshold codes); if that succeeds, **referential + semantic** checks run (dangling references, unused definitions, duplicate keyframe names, media-query syntax, and the rule-derived semantic warnings — same-element re-trigger, hit-area shift, scroll-preset `range`, `animationEnd` graph cycles, element-selection coherence, `fill`/`inset` nudges, CSS property names that are neither camelCase nor kebab-case). If the structural parse fails, the semantic layer is skipped. +- Validation runs in two layers: a **structural** zod parse first (produces `SCHEMA_*` and numeric/threshold codes); if that succeeds, **referential + semantic** checks run (dangling references, unused definitions, duplicate keyframe names, media-query syntax, and the rule-derived semantic warnings — same-element re-trigger, hit-area shift, scroll-preset `range`, `animationEnd` graph cycles, element-selection coherence, `fill`/`inset` nudges, CSS property names that are neither camelCase nor kebab-case, sequence easings that cannot be compiled to CSS). If the structural parse fails, the semantic layer is skipped. ### assertValidInteractConfig @@ -142,6 +142,7 @@ Severity is one of `'error' | 'warning'`. There are exactly two levers: | `POINTER_AXIS` | `POINTER_AXIS_IGNORED` | warning | | `CSS_PROPERTY_NAME` | `INVALID_CSS_PROPERTY_NAME` | warning | | `VIEW_INSET` | `INVALID_INSET` | warning | +| `OFFSET_EASING` | `FUNCTION_OFFSET_EASING` | warning | For each category, set `'off'` to drop those issues entirely, `'warning'` / `'error'` to set their severity: @@ -237,6 +238,7 @@ Statically-detectable authoring pitfalls lifted from the trigger rule files. Eac | `POINTER_AXIS_IGNORED` | `pointerMove` `params.axis` set on a `namedEffect`/`customEffect` (axis only applies to `keyframeEffect`). | `POINTER_AXIS` | | `INVALID_CSS_PROPERTY_NAME` | A keyframe or state-effect property name is neither camelCase nor kebab-case (both casings are accepted; this one cannot be normalized). | `CSS_PROPERTY_NAME` | | `INVALID_INSET` | `viewEnter` `params.inset` is not 1–4 whitespace-separated CSS lengths/percentages. | `VIEW_INSET` | +| `FUNCTION_OFFSET_EASING` | A sequence's `offsetEasing` is a function, so `generate()` omits that sequence from the generated CSS. | `OFFSET_EASING` | --- @@ -277,7 +279,7 @@ const ExperienceSchema = z.object({ - `InteractConfigSchema` is `.strict()` — unrecognized top-level keys produce `SCHEMA_UNRECOGNIZED_KEYS`. - `InteractConfigSchema` carries a `.transform()`, so a successful `.parse()` returns the config augmented with an internal `warnings` array; `validateInteractConfig` consumes that for you. -- `customEffect` and function-valued `offsetEasing` are accepted as opaque functions (`z.custom`) — they are not deep-validated, so JS-authored configs with function fields validate correctly. +- `customEffect` and function-valued `offsetEasing` are accepted as opaque functions (`z.custom`) — they are not deep-validated, so JS-authored configs with function fields validate correctly. A function `offsetEasing` is still structurally valid, but raises the `FUNCTION_OFFSET_EASING` warning because `generate()` cannot compile it to CSS. - Interactions and effects accept `$`-prefixed plugin fields (e.g. `$splitText`) — config routed to plugins registered via `Interact.use()`. Interaction/effect schemas use `.catchall(z.unknown())` + a key check instead of `.strict()`: `$`-prefixed fields are accepted with opaque values (validate has no knowledge of any plugin's shape), while any non-prefixed unknown key is still reported as `SCHEMA_UNRECOGNIZED_KEYS` (typo detection preserved). The top-level `InteractConfigSchema` stays `.strict()`. --- diff --git a/packages/interact/src/core/Interact.ts b/packages/interact/src/core/Interact.ts index 13b9ba06..cd6542e1 100644 --- a/packages/interact/src/core/Interact.ts +++ b/packages/interact/src/core/Interact.ts @@ -13,7 +13,6 @@ import { InteractPlugin, } from '../types'; import { getInterpolatedKey } from './utilities'; -import { generateId } from '../utils'; import TRIGGER_TO_HANDLER_MODULE_MAP from '../handlers'; import { registerEffects, @@ -401,7 +400,7 @@ function parseConfig(config: InteractConfig, useCustomElement: boolean = false): const { effects: effectMap = {}, sequences: sequenceMap = {}, conditions = {} } = config; const interactions: InteractCache['interactions'] = {}; - config.interactions?.forEach((interaction_) => { + config.interactions?.forEach((interaction_, configIndex) => { const source = interaction_.key; const interactionIdx = ++interactionIdCounter; const { effects: effects_, sequences: sequences_, ...rest } = interaction_; @@ -420,7 +419,7 @@ function parseConfig(config: InteractConfig, useCustomElement: boolean = false): effects.reverse(); // reverse to ensure the first effect is the one that will be applied first // Resolve and preprocess sequences - const processedSequences = sequences_?.map((seqOrRef) => { + const processedSequences = sequences_?.map((seqOrRef, sequenceIndex) => { if (_isSequenceConfigRef(seqOrRef)) { const resolved = sequenceMap[seqOrRef.sequenceId]; if (!resolved) { @@ -432,7 +431,7 @@ function parseConfig(config: InteractConfig, useCustomElement: boolean = false): const seq = seqOrRef as SequenceConfig; if (!seq.sequenceId) { - seq.sequenceId = generateId(); + seq.sequenceId = `seq-${configIndex}-${sequenceIndex}`; } return seq; }); @@ -450,7 +449,7 @@ function parseConfig(config: InteractConfig, useCustomElement: boolean = false): const listContainer = interaction.listContainer; - effects.forEach((effect) => { + effects.forEach((effect, effectIndex) => { /* * Target cascade order is the first of: * -> Config.interactions.effects.effect.key @@ -468,7 +467,7 @@ function parseConfig(config: InteractConfig, useCustomElement: boolean = false): } if (!(effect as EffectRef).effectId) { - (effect as EffectRef).effectId = generateId(); + (effect as EffectRef).effectId = `eff-${configIndex}-${effects.length - 1 - effectIndex}`; } // if no target is specified, use the source element as the target @@ -510,12 +509,12 @@ function parseConfig(config: InteractConfig, useCustomElement: boolean = false): if (!seqConfig || _isSequenceConfigRef(seqConfig)) return; const sequenceConfig = seqConfig as SequenceConfig; - const sequenceId = sequenceConfig.sequenceId || generateId(); + const sequenceId = sequenceConfig.sequenceId!; const seqEffects = sequenceConfig.effects; - for (const effect of seqEffects) { + seqEffects.forEach((effect, effectIndex) => { if (!(effect as EffectRef).effectId) { - (effect as EffectRef).effectId = generateId(); + (effect as EffectRef).effectId = `eff-${sequenceId}-${effectIndex}`; } let target = effect.key; @@ -547,7 +546,7 @@ function parseConfig(config: InteractConfig, useCustomElement: boolean = false): }); targetEntry.selectors.add(effectSelector); } - } + }); }); }); diff --git a/packages/interact/src/core/css.ts b/packages/interact/src/core/css.ts index 0f8b9346..0ce1bb72 100644 --- a/packages/interact/src/core/css.ts +++ b/packages/interact/src/core/css.ts @@ -211,6 +211,7 @@ function effectToCSS( trigger: TriggerVariant, childSelector?: string, plugins?: InteractPluginStyles, + sequence?: ResolvedSequence, ): { rules: CSSRuleData[]; keyframes: MotionKeyframeEffect[]; @@ -253,7 +254,7 @@ function effectToCSS( usedProperties = [...LIST_ANIMATION_PROPERTY_NAMES]; const animationOptions = effectToAnimationOptions(effect); - const cssAnimations = getCSSAnimation(null, animationOptions, trigger).filter( + const cssAnimations = getCSSAnimation(null, animationOptions, trigger, sequence).filter( (anim) => anim.name, ); @@ -350,6 +351,7 @@ function parseEffect( plugins?: InteractPluginStyles, sequenceCustomProps?: Record, precomputedTargetHash?: string, + sequence?: ResolvedSequence, ): { rules: CSSRuleData[]; usedProperties: ListPropertyName[] } { const { key } = effect; const targetHash = precomputedTargetHash ?? getElementHash(effect); @@ -384,6 +386,7 @@ function parseEffect( trigger, childSelector, plugins, + sequence, ); // update keyframes map @@ -438,6 +441,7 @@ function parseSequence( plugins, seqCustomProps, targetHash, + sequence, ); cssRules.push(...rules); @@ -492,7 +496,9 @@ function parseInteraction( const targetUsedProperties = new Map>(); const resolvedEffects = effects - .map((effect) => resolveEffectForCSS(effect, interaction, config)) + .map((effect, effIndex) => + resolveEffectForCSS(effect, interaction, config, `eff-${interactionIdx}-${effIndex}`), + ) .filter((effect) => effect !== null); const cssRules = plugins @@ -533,7 +539,9 @@ function parseInteraction( } const resolvedSequences = sequences - .map((sequence) => resolveSequenceForCSS(sequence, interaction, config)) + .map((sequence, seqIndex) => + resolveSequenceForCSS(sequence, interaction, config, `seq-${interactionIdx}-${seqIndex}`), + ) .filter((sequence) => sequence !== null); cssRules.push( diff --git a/packages/interact/src/core/resolvers.ts b/packages/interact/src/core/resolvers.ts index c7946dd0..f13760d8 100644 --- a/packages/interact/src/core/resolvers.ts +++ b/packages/interact/src/core/resolvers.ts @@ -1,4 +1,4 @@ -import { MotionKeyframeEffect, NamedEffect, getJsEasing } from '@wix/motion'; +import { MotionKeyframeEffect, NamedEffect } from '@wix/motion'; import type { InteractConfig, Effect, @@ -13,7 +13,7 @@ import type { TimeAnimationTriggerType, TriggerType, } from '../types'; -import { isTemplatedKey, generateId, calculateSequenceEffectsOffsets } from '../utils'; +import { isTemplatedKey, generateId } from '../utils'; import { shouldUseInitial } from './utilities'; const TIME_TRIGGER_TO_DEFAULT_TYPE: Map = new Map([ @@ -29,6 +29,7 @@ export function resolveEffectForCSS( effect: Effect | EffectRef, interaction: Interaction, config: InteractConfig, + fallbackId?: string, ): ResolvedEffect | null { const { effects = {}, conditions: configConditions = {} } = config; const { key: interactionKey, trigger } = interaction; @@ -36,7 +37,7 @@ export function resolveEffectForCSS( // ensuring the original refernce of the effect has an id (required for states) if (!effect.effectId) { - effect.effectId = generateId(); + effect.effectId = fallbackId || generateId(); } const { effectId } = effect; @@ -104,12 +105,12 @@ export function resolveSequenceForCSS( sequence: SequenceConfig | SequenceConfigRef, interaction: Interaction, config: InteractConfig, + fallbackId?: string, ): ResolvedSequence | null { const { sequences = {}, conditions: configConditions = {} } = config; - // required? if (!sequence.sequenceId) { - sequence.sequenceId = generateId(); + sequence.sequenceId = fallbackId || generateId(); } const { sequenceId } = sequence; @@ -124,6 +125,10 @@ export function resolveSequenceForCSS( offsetEasing = 'linear', } = fullSequence; + if (typeof offsetEasing === 'function') { + return null; // CSS does not support JS functions for easing + } + if (!triggerType) { triggerType = TIME_TRIGGER_TO_DEFAULT_TYPE.get(interaction.trigger)!; } @@ -141,12 +146,6 @@ export function resolveSequenceForCSS( return resolveEffectForCSS({ ...effect, triggerType }, interaction, config); }); - // resolving offsets - if (!(typeof offsetEasing === 'function')) { - offsetEasing = getJsEasing(offsetEasing) || ((x) => x); - } - calculateSequenceEffectsOffsets(resolvedEffects, delay, offset, offsetEasing); - // removing unsupported effects and the whole sequence if all are unsupported const filteredEffects = resolvedEffects.filter((effect) => effect !== null); if (!filteredEffects.length) { diff --git a/packages/interact/src/types/config.ts b/packages/interact/src/types/config.ts index ddfddea1..d272b754 100644 --- a/packages/interact/src/types/config.ts +++ b/packages/interact/src/types/config.ts @@ -71,7 +71,7 @@ export type ResolvedSequence = { triggerType: TimeAnimationTriggerType; delay: number; offset: number; - offsetEasing: (p: number) => number; + offsetEasing: string; conditions: string[]; effects: ResolvedEffect[]; }; diff --git a/packages/interact/src/utils.ts b/packages/interact/src/utils.ts index 54a47e0c..ea586bbc 100644 --- a/packages/interact/src/utils.ts +++ b/packages/interact/src/utils.ts @@ -17,22 +17,6 @@ export function camelToKebabCase(property: string): string { return property.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`); } -export function calculateSequenceEffectsOffsets( - effects: ((any & { delay?: number }) | null)[], - delay: number, - offset: number, - offsetEasing: (p: number) => number, -): void { - const maxIndex = effects.length - 1; - - effects.forEach((effect, index) => { - if (effect) { - const safeOffset = index ? (offsetEasing(index / maxIndex) * maxIndex * offset) | 0 : 0; - effect.delay = delay + safeOffset + (effect.delay || 0); - } - }); -} - /** * Applies a selector condition predicate to a base selector. * - If `&` is in the predicate, replace `&` with the base selector diff --git a/packages/interact/test/css.spec.ts b/packages/interact/test/css.spec.ts index 934e3efa..7a56a74c 100644 --- a/packages/interact/test/css.spec.ts +++ b/packages/interact/test/css.spec.ts @@ -1276,6 +1276,99 @@ describe('css._generate', () => { expect(rangeDecl).toBeDefined(); }); + describe('staggered delay', () => { + const staggerConfig = ( + sequence: Partial[number] = {}, + ): InteractConfig => ({ + effects: {}, + interactions: [ + { + key: 'el', + trigger: 'click', + sequences: [ + { + offset: 120, + ...sequence, + effects: [ + { + effectId: 'kf1', + duration: 300, + keyframeEffect: { + name: 'anim1', + keyframes: [{ opacity: '0' }, { opacity: '1' }], + }, + }, + ], + } as any, + ], + }, + ], + }); + + const animationValue = (config: InteractConfig) => { + const { cssRules } = _generate(config); + + return cssRules + .flatMap((r) => r.declarations) + .filter((d) => isAnimationProp(d.name) && !String(d.value).includes('var(--animation')) + .map((d) => String(d.value)) + .join('\n'); + }; + + it('should express the stagger as a calc() over the sequence index custom properties', () => { + const value = animationValue(staggerConfig()); + + expect(value).toContain('calc('); + expect(value).toContain('var(--motion-seq-0-0-index, 0)'); + expect(value).toContain('var(--motion-seq-0-0-last, 1)'); + }); + + it('should derive the sequence id from the config position so the runtime matches', () => { + const config = staggerConfig(); + const second = staggerConfig().interactions[0]; + second.key = 'el2'; + config.interactions.push(second); + + const value = animationValue(config); + + expect(value).toContain('--motion-seq-0-0-index'); + expect(value).toContain('--motion-seq-1-0-index'); + }); + + it('should honour an explicit sequenceId', () => { + expect(animationValue(staggerConfig({ sequenceId: 'my-seq' }))).toContain( + 'var(--motion-my-seq-index, 0)', + ); + }); + + it('should fold the sequence delay into the calc() base', () => { + expect(animationValue(staggerConfig({ delay: 40 }))).toContain('calc((40 +'); + }); + + it('should apply the offsetEasing inside the calc()', () => { + const ratio = '(var(--motion-seq-0-0-index, 0) / var(--motion-seq-0-0-last, 1))'; + + expect(animationValue(staggerConfig({ offsetEasing: 'quadIn' }))).toContain( + `${ratio} * ${ratio}`, + ); + }); + + it('should emit a plain delay when the sequence has no offset', () => { + const value = animationValue(staggerConfig({ offset: 0, delay: 40 })); + + expect(value).not.toContain('calc('); + expect(value).toContain('40ms'); + }); + + it('should skip a sequence whose offsetEasing is a function', () => { + const { cssRules } = _generate(staggerConfig({ offsetEasing: (p: number) => p ** 2 })); + + expect( + cssRules.flatMap((r) => r.declarations).filter((d) => isAnimationProp(d.name)), + ).toHaveLength(0); + }); + }); + it('should apply sequence-level conditions to the coordinated-list rule', () => { const config: InteractConfig = { effects: {}, diff --git a/packages/interact/test/resolvers.spec.ts b/packages/interact/test/resolvers.spec.ts index b132549a..4e950d64 100644 --- a/packages/interact/test/resolvers.spec.ts +++ b/packages/interact/test/resolvers.spec.ts @@ -67,6 +67,21 @@ describe('css resolvers', () => { it('should generate id if effectId does not exist', () => { expect(resolveEffectForCSS({}, BASE_INTERACTION, EMPTY_CONFIG)?.effectId).toBeTruthy(); }); + it('should prefer the deterministic fallback id over a generated one', () => { + const effect = {}; + const result = resolveEffectForCSS(effect, BASE_INTERACTION, EMPTY_CONFIG, 'eff-0-1'); + expect(result?.effectId).toBe('eff-0-1'); + expect((effect as EffectRef).effectId).toBe('eff-0-1'); + }); + it('should not override an existing effectId with the fallback id', () => { + const result = resolveEffectForCSS( + { effectId: 'mine' }, + BASE_INTERACTION, + EMPTY_CONFIG, + 'eff-0-1', + ); + expect(result?.effectId).toBe('mine'); + }); }); describe('conditions', () => { @@ -255,14 +270,35 @@ describe('css resolvers', () => { resolveSequenceForCSS(BASE_SEQUENCE, BASE_INTERACTION, EMPTY_CONFIG)?.sequenceId, ).toBeTruthy(); }); + it('should prefer the deterministic fallback id, so CSS and runtime agree on the var names', () => { + const sequence = { effects: [{}] }; + const result = resolveSequenceForCSS(sequence, BASE_INTERACTION, EMPTY_CONFIG, 'seq-0-1'); + expect(result?.sequenceId).toBe('seq-0-1'); + expect(sequence).toMatchObject({ sequenceId: 'seq-0-1' }); + }); }); describe('delay, offset, offsetEasing', () => { - it('should default to 0, 0, linear(function)', () => { + it("should default to 0, 0, 'linear'", () => { const result = resolveSequenceForCSS(BASE_SEQUENCE, BASE_INTERACTION, EMPTY_CONFIG); - expect(result).toMatchObject({ delay: 0, offset: 0 }); - const randomVal = Math.random(); - expect(result?.offsetEasing(randomVal)).toBe(randomVal); + expect(result).toMatchObject({ delay: 0, offset: 0, offsetEasing: 'linear' }); + }); + it('should pass the offsetEasing string through untouched for the CSS calc()', () => { + const result = resolveSequenceForCSS( + { ...BASE_SEQUENCE, offsetEasing: 'cubic-bezier(0.25, 0.1, 0.25, 1)' }, + BASE_INTERACTION, + EMPTY_CONFIG, + ); + expect(result?.offsetEasing).toBe('cubic-bezier(0.25, 0.1, 0.25, 1)'); + }); + it('should return null for a function offsetEasing - CSS cannot express it', () => { + expect( + resolveSequenceForCSS( + { ...BASE_SEQUENCE, offsetEasing: (p: number) => p ** 2 }, + BASE_INTERACTION, + EMPTY_CONFIG, + ), + ).toBeNull(); }); }); @@ -360,20 +396,21 @@ describe('css resolvers', () => { expect(result?.effects[0].conditions).toContain('condition'); expect(result?.effects[1].conditions).toContain('condition'); }); - it('should add offsets (delay) to all individual effects', () => { + it('should leave effect delays untouched - stagger is applied by the CSS calc()', () => { const result = resolveSequenceForCSS( { delay: 100, offset: 50, - effects: [{ effectId: 'e1' }, { effectId: 'e2' }], + effects: [{ effectId: 'e1' }, { effectId: 'e2', delay: 20 }], }, BASE_INTERACTION, EMPTY_CONFIG, ); - expect((result?.effects[0] as any).delay).toBe(100); - expect((result?.effects[1] as any).delay).toBe(150); + expect((result?.effects[0] as any).delay).toBeUndefined(); + expect((result?.effects[1] as any).delay).toBe(20); + expect(result).toMatchObject({ delay: 100, offset: 50 }); }); - it('should add correct offsets to effects by original order (even if null after resolving)', () => { + it('should drop unsupported effects while keeping the sequence timing', () => { const result = resolveSequenceForCSS( { offset: 100, @@ -387,8 +424,8 @@ describe('css resolvers', () => { EMPTY_CONFIG, ); expect(result?.effects).toHaveLength(2); - expect((result?.effects[0] as any).delay).toBe(0); - expect((result?.effects[1] as any).delay).toBe(200); + expect(result?.effects.map((e) => e.effectId)).toEqual(['e1', 'e3']); + expect(result?.offset).toBe(100); }); }); }); diff --git a/packages/interact/test/sequences.spec.ts b/packages/interact/test/sequences.spec.ts index 6ca12ada..b5b1a766 100644 --- a/packages/interact/test/sequences.spec.ts +++ b/packages/interact/test/sequences.spec.ts @@ -190,6 +190,61 @@ describe('interact sequences', () => { expect(triggerSequence.sequenceId).toBeTruthy(); }); + // The generated CSS names its stagger custom properties `--motion--index`, so a + // config-position-derived id is what lets the runtime Sequence address the very same properties. + test('derives auto-generated sequenceIds from the config position, matching generate()', () => { + const config = createBaseConfig(); + config.interactions = [ + { + trigger: 'click', + key: 'source-key', + sequences: [{ effects: [{ effectId: 'effect-source' }] }], + }, + { + trigger: 'click', + key: 'other-key', + sequences: [ + { effects: [{ effectId: 'effect-source' }] }, + { effects: [{ effectId: 'effect-source' }] }, + ], + }, + ]; + + const instance = Interact.create(config, { useCustomElement: false }); + const idsFor = (key: string) => + instance.dataCache.interactions[key].triggers[0].sequences?.map( + (s) => (s as SequenceConfig).sequenceId, + ); + + expect(idsFor('source-key')).toEqual(['seq-0-0']); + expect(idsFor('other-key')).toEqual(['seq-1-0', 'seq-1-1']); + }); + + test('derives auto-generated effectIds from the config position', () => { + const config = createBaseConfig(); + config.interactions = [ + { + trigger: 'click', + key: 'source-key', + effects: [{ duration: 100 }, { duration: 200 }], + sequences: [{ effects: [{ duration: 300 }, { duration: 400 }] }], + }, + ]; + + Interact.create(config, { useCustomElement: false }); + + // effects keep their config order, sequence effects are namespaced under the sequence id + expect(config.interactions[0].effects?.map((e: any) => e.effectId)).toEqual([ + 'eff-0-0', + 'eff-0-1', + ]); + expect( + (config.interactions[0].sequences?.[0] as SequenceConfig).effects.map( + (e: any) => e.effectId, + ), + ).toEqual(['eff-seq-0-0-0', 'eff-seq-0-0-1']); + }); + test('warns when referencing unknown sequenceId', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const config = createBaseConfig(); diff --git a/packages/motion/README.md b/packages/motion/README.md index 599d4e45..c8b0ebcf 100644 --- a/packages/motion/README.md +++ b/packages/motion/README.md @@ -148,6 +148,10 @@ const sequence = getSequence( sequence.play(); ``` +Add a `sequenceId` to stagger CSS-rendered animations too: `getCSSAnimation()` accepts the same options and +emits a single rule whose delay is a `calc()` over `--motion--index`, which the `Sequence` then +sets per element. See [CSS-Driven Stagger](./docs/api/sequence.md#css-driven-stagger). + See [`docs/api/get-sequence.md`](https://github.com/wix/interact/blob/master/packages/motion/docs/api/get-sequence.md) for the full stagger model. ## ViewTimeline and Polyfills diff --git a/packages/motion/docs/api/README.md b/packages/motion/docs/api/README.md index ad9d37b2..4d0fe6ef 100644 --- a/packages/motion/docs/api/README.md +++ b/packages/motion/docs/api/README.md @@ -14,6 +14,7 @@ Index of everything `@wix/motion` exports — functions, classes, and types — | `registerEffects()` | Register named effect modules into the global registry | `void` | [core-functions.md#registereffects](./core-functions.md#registereffects) | | `getEasing()` | Resolve a named/raw easing to a CSS easing string | `string` | [core-functions.md#geteasing--getjseasing](./core-functions.md#geteasing--getjseasing) | | `getJsEasing()` | Resolve a named/raw easing to a JS easing function | `((t: number) => number) \| undefined` | [core-functions.md#geteasing--getjseasing](./core-functions.md#geteasing--getjseasing) | +| `getJsEasingInCSS()` | Compile a named/raw easing into a CSS `calc()` expression builder | `((t: string) => string) \| undefined` | [core-functions.md#getjseasingincss](./core-functions.md#getjseasingincss) | | `getSequence()` | Coordinate multiple `AnimationGroup`s with staggered offsets | `Sequence` | [get-sequence.md#getsequence](./get-sequence.md#getsequence) | | `createAnimationGroups()` | Build `AnimationGroup`s from target/options pairs without a `Sequence` wrapper | `AnimationGroup[]` | [get-sequence.md#createanimationgroups](./get-sequence.md#createanimationgroups) | diff --git a/packages/motion/docs/api/core-functions.md b/packages/motion/docs/api/core-functions.md index 7806f645..abad29b6 100644 --- a/packages/motion/docs/api/core-functions.md +++ b/packages/motion/docs/api/core-functions.md @@ -80,6 +80,7 @@ function getCSSAnimation( target: string | null, animationOptions: AnimationOptions, trigger?: TriggerVariant, + sequenceOptions?: SequenceOptions, ): Array<{ target: string; animation: string; @@ -95,11 +96,12 @@ function getCSSAnimation( ### Parameters -| Parameter | Type | Description | -| ------------------ | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `target` | `string \| null` | Element id or CSS selector. CSS rules target selectors, so — unlike `getWebAnimation` — an `HTMLElement` reference is not accepted here. | -| `animationOptions` | `AnimationOptions` | Same shape as `getWebAnimation`. | -| `trigger` | `TriggerVariant` | Optional. `view-progress` animations always resolve to `duration: 'auto'` through this function, regardless of runtime `ViewTimeline` support (the SSR-safe `forCSS` path). | +| Parameter | Type | Description | +| ------------------ | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `target` | `string \| null` | Element id or CSS selector. CSS rules target selectors, so — unlike `getWebAnimation` — an `HTMLElement` reference is not accepted here. | +| `animationOptions` | `AnimationOptions` | Same shape as `getWebAnimation`. | +| `trigger` | `TriggerVariant` | Optional. `view-progress` animations always resolve to `duration: 'auto'` through this function, regardless of runtime `ViewTimeline` support (the SSR-safe `forCSS` path). | +| `sequenceOptions` | `SequenceOptions` | Optional. When it carries a `sequenceId` and a non-zero `offset`, the delay slot of the `animation` shorthand becomes a `calc()` staggered by `--motion--index`. See [Staggered Sequences](#staggered-sequences). | ### Returns @@ -158,6 +160,31 @@ document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet]; // descriptors.map((d) => `@keyframes ${d.name} { ... } ${d.target} { animation: ${d.animation}; }`).join('\n') ``` +### Staggered Sequences + +Staggering a list from static CSS would normally need one rule per item, since each item needs a different `animation-delay` — and at generation time the item count isn't known. Passing `sequenceOptions` avoids that: the delay becomes a `calc()` over two custom properties, so a **single rule** staggers any number of elements. + +```typescript +const [{ animation }] = getCSSAnimation( + 'card', + { namedEffect: { type: 'FadeIn' }, duration: 600 }, + undefined, + { sequenceId: 'cards', offset: 120, offsetEasing: 'quadIn' }, +); +// fade-in 600ms calc((0 + * 120 * var(--motion-cards-last, 1)) * 1ms) … paused +``` + +`--motion-cards-index` and `--motion-cards-last` are written per element at runtime by a [`Sequence`](./sequence.md#css-driven-stagger) constructed with the same `sequenceId`. Before that happens the `var()` fallbacks (`index: 0`, `last: 1`) resolve the `calc()` to the plain base delay, so the CSS stays valid and FOUC-free with no JS. + +| `sequenceOptions` | Emitted delay | +| ---------------------------------- | ------------------------------------------------- | +| omitted, or without a `sequenceId` | `ms` | +| `sequenceId` + non-zero `offset` | the staggered `calc()` | +| `sequenceId` + `offset: 0`/omitted | `ms` | +| `offsetEasing` given as a function | `ms` — a JS function has no CSS equivalent | + +`offsetEasing` accepts the same strings as `getJsEasing` (named key, `cubic-bezier(...)`, `linear(...)`) and defaults to `'linear'`; it is compiled to a `calc()` fragment by [`getJsEasingInCSS`](#getjseasingincss). + ## getScrubScene Builds scroll-polyfill or pointer-driven scrub scenes for cases where a native `ViewTimeline` isn't available or driving. @@ -401,6 +428,41 @@ const ease = getJsEasing('backOut'); // → (t: number) => number getJsEasing(); // → undefined (falsy input) ``` +## getJsEasingInCSS + +Compiles an easing into a CSS `calc()` **expression builder**, for cases where the input isn't known until the browser evaluates the stylesheet. Used to express a sequence's `offsetEasing` in generated CSS — see [Staggered Sequences](#staggered-sequences). + +### Signature + +```typescript +function getJsEasingInCSS(easing?: string): ((t: string) => string) | undefined; +``` + +### Parameters + +| Parameter | Type | Description | +| --------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `easing` | `string` | Optional. Same inputs as `getJsEasing`: a named key, a `cubic-bezier(x1, y1, x2, y2)` string, or a CSS `linear(...)` string. | + +### Returns + +`((t: string) => string) | undefined` — a builder that substitutes any CSS expression for the easing's input and returns the eased expression. Returns `undefined` only when `easing` is falsy; anything unparsable falls back to the linear builder. + +Unlike `getJsEasing`, this returns a **string builder, not a numeric function** — it never evaluates the curve, it emits math for the browser to evaluate. The named keys are the same as `getJsEasing`, plus `ease`, `easeIn`, `easeOut`, and `easeInOut`. + +### Example + +```typescript +import { getJsEasingInCSS } from '@wix/motion'; + +const easing = getJsEasingInCSS('quadIn')!; + +easing('var(--p)'); // → '(var(--p) * var(--p))' +getJsEasingInCSS(); // → undefined (falsy input) +``` + +> **Browser support**: the emitted expressions use the CSS math functions `pow()`, `sqrt()`, `sin()`, `cos()`, `acos()`, `round()`, `max()` and `clamp()`. + --- **Next**: See [Sequence Creation](./get-sequence.md) for `getSequence()` / `createAnimationGroups()`, or return to the [API Reference](./README.md). diff --git a/packages/motion/docs/api/get-sequence.md b/packages/motion/docs/api/get-sequence.md index c187bb8b..bceb5367 100644 --- a/packages/motion/docs/api/get-sequence.md +++ b/packages/motion/docs/api/get-sequence.md @@ -27,9 +27,12 @@ type SequenceOptions = { delay?: number; // Base delay (ms), default 0 offset?: number; // Stagger interval (ms), default 0 offsetEasing?: string | ((p: number) => number); // Easing for offset distribution + sequenceId?: string; // Links the Sequence to CSS generated for the same id }; ``` +`sequenceId` opts CSS-backed groups into the [CSS-driven stagger](./sequence.md#css-driven-stagger): rather than writing each group's `delay` through the WAAPI, the Sequence sets `--motion--index` / `--motion--last` on the target elements, which the `calc()` delay emitted by `getCSSAnimation()` reads. Pass the same id to both. + #### `animationGroups` (required) Array of target/options pairs. Each entry is resolved into one or more `AnimationGroup` instances: diff --git a/packages/motion/docs/api/sequence.md b/packages/motion/docs/api/sequence.md index a2d48180..2db995ae 100644 --- a/packages/motion/docs/api/sequence.md +++ b/packages/motion/docs/api/sequence.md @@ -12,6 +12,7 @@ The `Sequence` class coordinates multiple `AnimationGroup` instances as a unifie - **Dynamic Groups** - Add or remove groups at runtime with automatic offset recalculation - **Unified Playback** - Play, pause, reverse, and cancel all child animations together - **Easing-Driven Offsets** - Use named easings (`quadIn`, `sineOut`) or custom functions to shape stagger curves +- **CSS-Driven Stagger** - With a `sequenceId`, CSS-backed groups are staggered by custom properties instead of WAAPI timing ## Class Definition @@ -21,6 +22,7 @@ class Sequence extends AnimationGroup { delay: number; offset: number; offsetEasing: (p: number) => number; + sequenceId: string | undefined; constructor(animationGroups: AnimationGroup[], options?: SequenceOptions); @@ -68,12 +70,14 @@ type SequenceOptions = { delay?: number; offset?: number; offsetEasing?: string | ((p: number) => number); + sequenceId?: string; }; ``` - **`delay`** - Base delay (ms) applied to all groups on top of their stagger offset. Default: `0`. - **`offset`** - Stagger interval (ms) between consecutive groups. Default: `0`. - **`offsetEasing`** - Easing function or named string that shapes the distribution of offsets. Accepts a `(p: number) => number` function, a named easing string (`'linear'`, `'quadIn'`, `'sineOut'`, etc.), or a `cubic-bezier(...)` string. Default: `linear`. +- **`sequenceId`** - Identifier shared with the CSS generated by [`getCSSAnimation()`](./core-functions.md#getcssanimation). Enables the [CSS-driven stagger](#css-driven-stagger) for groups whose animations came from CSS. Default: `undefined`. ### Examples @@ -134,6 +138,14 @@ offsetEasing: (p: number) => number; Easing function that distributes stagger offsets. Receives a normalized progress value (0–1) representing the group's position in the sequence and returns a normalized output. Named strings are resolved via `getJsEasing()` at construction time. +### `sequenceId` + +```typescript +sequenceId: string | undefined; +``` + +Identifier tying this Sequence to CSS generated for the same id. See [CSS-Driven Stagger](#css-driven-stagger). + ### `animations` ```typescript @@ -172,7 +184,48 @@ Given 5 groups with `offset: 200`: Single-group sequences always produce `[0]` regardless of offset or easing. -Each calculated offset is added to the group's animation `delay` timing. An `endDelay` is also computed so that all groups share the same total active duration, enabling the `finished` promise to resolve at the correct time. +Each calculated offset is added to the group's animation `delay` timing, with the sequence-level `delay` added on top. An `endDelay` is also computed so that all groups share the same total active duration, enabling the `finished` promise to resolve at the correct time. The sequence-level `delay` shifts the whole timeline, so it does not participate in the `endDelay` calculation. + +## CSS-Driven Stagger + +When an animation comes from CSS rather than the WAAPI — as it does for SSR/FOUC-free rendering, where [`getCSSAnimation()`](./core-functions.md#getcssanimation) emits the `animation` shorthand ahead of time — its delay belongs to the CSS declaration. Writing to it with `updateTiming({ delay })` would detach the animation from that declaration, so `Sequence` instead hands the CSS the one thing it can't know statically: **where each element sits in the sequence**. + +Pass a `sequenceId` and, for every child group whose animations are CSS Animations (`AnimationGroup.isCSS`), the Sequence sets two custom properties on the group's target element: + +| Custom property | Value | +| ----------------------------- | ---------------------------- | +| `--motion--index` | the group's 0-based index | +| `--motion--last` | the index of the final group | + +The generated CSS reads them from a `calc()` in the delay slot, so a single static rule staggers any number of elements: + +```typescript +import { getCSSAnimation, getSequence } from '@wix/motion'; + +const sequenceOptions = { sequenceId: 'cards', offset: 120, offsetEasing: 'quadIn' }; + +// build time — one rule, delay driven by the (not yet set) index properties +const [descriptor] = getCSSAnimation( + 'card', + { namedEffect: { type: 'FadeIn' }, duration: 600 }, + undefined, + sequenceOptions, +); + +// runtime — the same id, so the Sequence fills in each element's index +const sequence = getSequence( + sequenceOptions, + cards.map((target) => ({ target, options: { namedEffect: { type: 'FadeIn' }, duration: 600 } })), +); +``` + +Notes: + +- The **same `sequenceId`** must reach both halves. They are joined only by the custom-property name; a mismatch silently produces no stagger, because the CSS `var()` fallbacks resolve every element's index to `0`. +- Groups that are not CSS-backed, and any Sequence without a `sequenceId`, keep using `updateTiming({ delay })` as before. +- `endDelay` is applied either way, so `finished` / `onFinish` are unaffected. +- `addGroups()` / `removeGroups()` rewrite the index properties for every remaining group, just as they recalculate WAAPI delays. +- A function `offsetEasing` has no CSS equivalent, so the CSS half falls back to an unstaggered delay — use a string easing when generating CSS. ## Group Management diff --git a/packages/motion/docs/api/types.md b/packages/motion/docs/api/types.md index eb0f34ef..095e0d48 100644 --- a/packages/motion/docs/api/types.md +++ b/packages/motion/docs/api/types.md @@ -228,9 +228,12 @@ type SequenceOptions = { delay?: number; // ms base delay, default 0 offset?: number; // ms stagger interval, default 0 offsetEasing?: string | ((p: number) => number); + sequenceId?: string; // links the Sequence to CSS generated for the same id }; ``` +`SequenceOptions` is also the optional 4th argument to `getCSSAnimation()`, which compiles `delay`/`offset`/`offsetEasing` into a `calc()` delay driven by `--motion--index`. See [CSS-Driven Stagger](./sequence.md#css-driven-stagger). + ### `AnimationGroupArgs` One entry in the array passed to `getSequence()` / `createAnimationGroups()`. diff --git a/packages/motion/docs/core-concepts.md b/packages/motion/docs/core-concepts.md index d86f8676..d5ee9de2 100644 --- a/packages/motion/docs/core-concepts.md +++ b/packages/motion/docs/core-concepts.md @@ -95,12 +95,14 @@ If you're using `@wix/interact`, its bundled scroll polyfill, [`fizban`](https:/ ```typescript function getEasing(easing?: string): string; // CSS easing string, default 'linear' function getJsEasing(easing?: string): ((t: number) => number) | undefined; // JS easing fn +function getJsEasingInCSS(easing?: string): ((t: string) => string) | undefined; // calc() builder ``` - **JS easings** (Penner functions — used by `getJsEasing` and as a `Sequence`'s `offsetEasing`): `linear`, `sineIn`, `sineOut`, `sineInOut`, `quadIn`, `quadOut`, `quadInOut`, `cubicIn`, `cubicOut`, `cubicInOut`, `quartIn`, `quartOut`, `quartInOut`, `quintIn`, `quintOut`, `quintInOut`, `expoIn`, `expoOut`, `expoInOut`, `circIn`, `circOut`, `circInOut`, `backIn`, `backOut`, `backInOut`. - **CSS easings** (used by `getEasing` / the `easing` option): `linear`, `ease`, `easeIn`, `easeOut`, `easeInOut`, plus every JS key above (except `linear`/`ease*`) resolving to a `cubic-bezier(...)` string. - Both also accept a raw `cubic-bezier(x1, y1, x2, y2)` string (hyphenated — not `cubicBezier(...)`), and `getJsEasing` additionally parses CSS `linear(...)` strings. - Standard CSS timing-function keywords (like `ease-out`) work as-is wherever `easing` is accepted — they don't need to match one of the named keys above. +- **CSS-expression easings** (`getJsEasingInCSS`): the same curves as `getJsEasing`, plus `ease`/`easeIn`/`easeOut`/`easeInOut`, emitted as `calc()` string fragments rather than functions. Used to compile a `Sequence`'s `offsetEasing` into generated CSS — see [SSR & CSS Generation](./guides/ssr-css.md#staggering-a-list-from-one-rule). There is no `easeOutCubic`, `elasticOut`, `bounceOut`, or `bounceIn` — those names don't exist. (`elastic`/`bounce` exist only as `transitionEasing` values for pointer smoothing, a separate field.) diff --git a/packages/motion/docs/guides/ssr-css.md b/packages/motion/docs/guides/ssr-css.md index 903f8a59..409ccde0 100644 --- a/packages/motion/docs/guides/ssr-css.md +++ b/packages/motion/docs/guides/ssr-css.md @@ -16,6 +16,7 @@ function getCSSAnimation( target: string | null, animationOptions: AnimationOptions, trigger?: TriggerVariant, + sequenceOptions?: SequenceOptions, ): Array<{ target: string; animation: string; @@ -33,17 +34,17 @@ function getCSSAnimation( rules target selectors, so there's no element reference to accept. One entry is returned per generated `@keyframes`/`animation` pair (e.g. one per `data-motion-part` sub-target). -| Field | Meaning | -| ------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -| `target` | Selector for the animated element or sub-part, e.g. `"#hero"` or `"#hero[data-motion-part~='icon']"`; `""` if nothing resolved. | -| `animation` | The CSS `animation` shorthand value. **Paused by default** — see [below](#paused-by-default). | -| `composition` | `CompositeOperation`, if the effect set one. | -| `custom` | Custom property values referenced by the keyframes, if any. | -| `name` | The `@keyframes` name — pair it with `keyframes` to build the `@keyframes` block. | -| `keyframes` | Ordered keyframe declarations (property-bag objects, not a WAAPI `Keyframe[]`) — the steps of the `@keyframes` block. | -| `id` | Effect id, if `animationOptions.effectId` was set. | -| `animationTimeline` | `` `--${trigger.id}` `` for `view-progress` triggers, else `""`. Maps to the CSS `animation-timeline` property. | -| `animationRange` | e.g. `"cover 0% cover 100%"` for `view-progress` triggers, else `""`. Maps to `animation-range`. | +| Field | Meaning | +| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `target` | Selector for the animated element or sub-part, e.g. `"#hero"` or `"#hero[data-motion-part~='icon']"`; `""` if nothing resolved. | +| `animation` | The CSS `animation` shorthand value. **Paused by default** — see [below](#paused-by-default). The delay becomes a `calc()` when `sequenceOptions` is passed — see [Staggering a list from one rule](#staggering-a-list-from-one-rule). | +| `composition` | `CompositeOperation`, if the effect set one. | +| `custom` | Custom property values referenced by the keyframes, if any. | +| `name` | The `@keyframes` name — pair it with `keyframes` to build the `@keyframes` block. | +| `keyframes` | Ordered keyframe declarations (property-bag objects, not a WAAPI `Keyframe[]`) — the steps of the `@keyframes` block. | +| `id` | Effect id, if `animationOptions.effectId` was set. | +| `animationTimeline` | `` `--${trigger.id}` `` for `view-progress` triggers, else `""`. Maps to the CSS `animation-timeline` property. | +| `animationRange` | e.g. `"cover 0% cover 100%"` for `view-progress` triggers, else `""`. Maps to `animation-range`. | ## Building a stylesheet from descriptors @@ -120,6 +121,33 @@ client renders it. So it always passes an internal `forCSS` flag that forces `du generated CSS safe to render ahead of time: it's native-`ViewTimeline` CSS every time, which any browser that doesn't support `ViewTimeline` will simply treat as a paused/static animation rather than break. +## Staggering a list from one rule + +Staggering a list in static CSS looks like it needs one rule per item — each item wants its own +`animation-delay`, and the item count usually isn't known when the CSS is generated. The optional +`sequenceOptions` argument sidesteps that: the delay slot becomes a `calc()` that reads the element's +position in the list from two custom properties, so **one rule covers the whole list**. + +```typescript +const [{ animation }] = getCSSAnimation( + 'card', + { namedEffect: { type: 'FadeIn' }, duration: 600 }, + undefined, + { sequenceId: 'cards', offset: 120, offsetEasing: 'quadIn' }, +); +// fade-in 600ms calc((0 + * 120 * var(--motion-cards-last, 1)) * 1ms) … paused +``` + +`--motion-cards-index` and `--motion-cards-last` are set per element at runtime by a +[`Sequence`](../api/sequence.md#css-driven-stagger) built with the same `sequenceId` — the id is the only +thing joining the two halves, so it must match. Until the Sequence runs, the `var()` fallbacks +(`index: 0`, `last: 1`) collapse the `calc()` to the plain base delay, keeping the pre-JS render valid. + +`offsetEasing` must be a string here (a named key, `cubic-bezier(...)`, or `linear(...)`); a JS function +has no CSS equivalent and falls back to an unstaggered delay. It defaults to `'linear'`, and the emitted +expressions use the CSS math functions `pow()`, `sqrt()`, `sin()`, `cos()`, `acos()`, +`round()`, `max()` and `clamp()`. + ## Paused by default The `animation` shorthand generated for **time-based** animations is paused by default — the CSS is @@ -145,3 +173,4 @@ for the full contract. Reach for `getCSSAnimation()` directly only when you're g of the core API. - [Custom Effects](./custom-effects.md) — implement an effect module's optional `style()` hook to opt into this path. +- [Sequence](../api/sequence.md#css-driven-stagger) — the runtime half of the CSS stagger. diff --git a/packages/motion/rules/css-generation.md b/packages/motion/rules/css-generation.md index 2f39eb61..e58dd672 100644 --- a/packages/motion/rules/css-generation.md +++ b/packages/motion/rules/css-generation.md @@ -17,6 +17,7 @@ documents only the lower-level `@wix/motion` primitive it is built on. - [Package Boundary](#package-boundary) - [Signature](#signature) - [Return Shape: an Array of Descriptors](#return-shape-an-array-of-descriptors) +- [Sequence Stagger in CSS](#sequence-stagger-in-css) - [`forCSS` and `duration: 'auto'`](#forcss-and-duration-auto) - [Injecting the Output](#injecting-the-output) - [The `iterations` Idiom in CSS](#the-iterations-idiom-in-css) @@ -35,11 +36,11 @@ documents only the lower-level `@wix/motion` primitive it is built on. ## Signature ```typescript -// ../src/api/cssAnimations.ts:51-80 function getCSSAnimation( target: string | null, animationOptions: AnimationOptions, trigger?: TriggerVariant, + sequenceOptions?: SequenceOptions, // opt in to the CSS stagger — see below ): Array<{ target: string; animation: string; @@ -65,24 +66,67 @@ Unlike `getWebAnimation`, `target` here is `string | null` **only** — an eleme One descriptor is produced per `AnimationData` the effect's `web`/`style` returns (see [`./custom-effects.md`](./custom-effects.md)), so a multi-part effect yields multiple descriptors — -one per `part` (`../src/api/cssAnimations.ts:63-79`). - -| Field | Meaning | -| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `target` | `#` or `#[data-motion-part~=""]` (see [`./custom-effects.md#data-motion-part-sub-targeting`](./custom-effects.md#data-motion-part-sub-targeting)); `''` if `target` was `null`. | -| `animation` | The CSS `animation` shorthand: ` `. **Paused by default** for time-based/pointer animations; **not** paused for `view-progress` (the timeline governs playback instead) — see `getAnimationAsCSS`, `../src/api/cssAnimations.ts:14-32`. | -| `composition?` | The effect's `CompositeOperation` (`'replace' \| 'add' \| 'accumulate'`), if set — apply as `animation-composition` when building the rule; not embedded in the `animation` shorthand itself. | -| `custom?` | CSS custom properties the effect needs on the target (e.g. `--motion-rotate`) — apply as inline declarations alongside `animation`. | -| `name` | The `@keyframes` name — use it both to declare `@keyframes { … }` and it is already embedded in the `animation` shorthand. | -| `keyframes` | The keyframe list to render into the `@keyframes` block. | -| `id` | `${effectId}-${index + 1}` if the animation options had an `effectId`, else `undefined`. For tracking the descriptor back to its source effect — not itself required in the emitted CSS. | -| `animationTimeline` | `--${trigger.id}` when `trigger.trigger === 'view-progress'`, else `''` — apply as `animation-timeline`. | -| `animationRange` | e.g. `"cover 0% cover 100%"` for `view-progress`, else `''` — apply as `animation-range`. | +one per `part`. + +| Field | Meaning | +| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `target` | `#` or `#[data-motion-part~=""]` (see [`./custom-effects.md#data-motion-part-sub-targeting`](./custom-effects.md#data-motion-part-sub-targeting)); `''` if `target` was `null`. | +| `animation` | The CSS `animation` shorthand: ` `. `` becomes a `calc()` when `sequenceOptions` is passed (see [Sequence Stagger in CSS](#sequence-stagger-in-css)). **Paused by default** for time-based/pointer animations; **not** paused for `view-progress` (the timeline governs playback instead) — see `getAnimationAsCSS`. | +| `composition?` | The effect's `CompositeOperation` (`'replace' \| 'add' \| 'accumulate'`), if set — apply as `animation-composition` when building the rule; not embedded in the `animation` shorthand itself. | +| `custom?` | CSS custom properties the effect needs on the target (e.g. `--motion-rotate`) — apply as inline declarations alongside `animation`. | +| `name` | The `@keyframes` name — use it both to declare `@keyframes { … }` and it is already embedded in the `animation` shorthand. | +| `keyframes` | The keyframe list to render into the `@keyframes` block. | +| `id` | `${effectId}-${index + 1}` if the animation options had an `effectId`, else `undefined`. For tracking the descriptor back to its source effect — not itself required in the emitted CSS. | +| `animationTimeline` | `--${trigger.id}` when `trigger.trigger === 'view-progress'`, else `''` — apply as `animation-timeline`. | +| `animationRange` | e.g. `"cover 0% cover 100%"` for `view-progress`, else `''` — apply as `animation-range`. | + +## Sequence Stagger in CSS + +Pass a `SequenceOptions` as the 4th argument to stagger a group of elements **from a single static CSS +rule**. Instead of baking a different `animation-delay` per element (which would need one rule per item, +and therefore knowledge of the item count at generation time), the delay slot becomes a `calc()` that +reads the element's position from two custom properties +(`getAnimationAsCSS`): + +``` +calc(( + * * var(--motion--last, 1)) * 1ms) +``` + +where `index / last` is spelled `var(--motion--index, 0) / var(--motion--last, 1)`. + +```typescript +getCSSAnimation('card', { namedEffect: { type: 'FadeIn' }, duration: 600 }, undefined, { + sequenceId: 'cards', + offset: 120, + offsetEasing: 'quadIn', +})[0].animation; +// fade-in 600ms calc((0 + (…index/…last) * (…index/…last) * 120 * var(--motion-cards-last, 1)) * 1ms) … paused +``` + +The custom properties are set per element at runtime by `Sequence` (see +[`./sequences.md`](./sequences.md#css-driven-stagger-sequenceid)) — pass it the **same `sequenceId`**. +Until it runs, the `var()` fallbacks (`index: 0`, `last: 1`) resolve the `calc()` to the plain base delay, +so the emitted CSS is valid and FOUC-free before any JS loads. + +| `sequenceOptions` | Emitted delay slot | +| ---------------------------------- | ------------------------------------------------- | +| omitted, or no `sequenceId` | `ms` — unchanged | +| `sequenceId` + non-zero `offset` | the `calc()` above | +| `sequenceId` + `offset: 0`/omitted | `ms` — nothing to stagger | +| `offsetEasing` is a **function** | `ms` — a JS function has no CSS form | + +- **MUST NOT** pass a function `offsetEasing` and expect a stagger — only string easings + (named key, `cubic-bezier(...)`, or `linear(...)`) can be compiled to `calc()`, via + `getJsEasingInCSS` (`../src/utils.ts:324-334`). A function silently falls back to the plain delay; + `@wix/interact` instead drops the whole sequence from its generated CSS. +- **Rule:** `offsetEasing` defaults to `'linear'` here, matching `Sequence` — `{ sequenceId, offset }` + alone is enough to get an evenly-spaced stagger. +- **Rule:** the stagger only affects the delay slot, which is omitted entirely for `duration: 'auto'` + (`view-progress`) animations — sequences are time-based only. ## `forCSS` and `duration: 'auto'` -`getCSSAnimation` internally calls the shared `getEffectsData(..., forCSS = true)` -(`../src/api/cssAnimations.ts:60`). For a `view-progress` trigger, this **forces `duration: 'auto'` +`getCSSAnimation` internally calls the shared `getEffectsData(..., forCSS = true)`. For a `view-progress` trigger, this **forces `duration: 'auto'` regardless of whether the current runtime supports `window.ViewTimeline`**: ```typescript @@ -169,7 +213,6 @@ On the server, write the same `css` string into the rendered HTML's `` ins ## The `iterations` Idiom in CSS ```typescript -// ../src/api/cssAnimations.ts:30-31 !iterations || iterations === Infinity ? 'infinite' : iterations; ``` @@ -203,6 +246,9 @@ rule logic. (`./waapi.md`). - **Rule:** for full FOUC-prevention and declarative CSS generation, use `@wix/interact`'s `generate()` rather than reimplementing it against these descriptors. +- **Rule:** a `sequenceOptions.sequenceId` used here is only half the contract — the runtime + `Sequence` must receive the same id or the stagger never materializes. See + [`./sequences.md`](./sequences.md#css-driven-stagger-sequenceid). - A registered effect only participates in `getCSSAnimation` output if it implements the optional `style` member of `AnimationEffectAPI` — see [`./custom-effects.md`](./custom-effects.md). @@ -212,6 +258,7 @@ rule logic. reference. - [`./custom-effects.md`](./custom-effects.md) — the `AnimationEffectAPI`/`style()` contract that feeds `getCSSAnimation`, and `data-motion-part` sub-targeting. +- [`./sequences.md`](./sequences.md) — the runtime `Sequence` half of the CSS stagger contract. - `./scrub-scenes.md` — the native-`ViewTimeline`-vs-polyfill duration branch this file's `forCSS` override bypasses. - `../../interact/rules/integration.md` — `@wix/interact`'s `generate()`, which builds full diff --git a/packages/motion/rules/motion-main.md b/packages/motion/rules/motion-main.md index 845144b5..8b18f84d 100644 --- a/packages/motion/rules/motion-main.md +++ b/packages/motion/rules/motion-main.md @@ -101,7 +101,7 @@ runtime — type your consts accordingly. | Function | Signature | Returns | Source | | ----------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | | `getWebAnimation` | `(target, animationOptions, trigger?, options?, ownerDocument?)` | `AnimationGroup \| MouseAnimationInstance \| null` | `../src/api/webAnimations.ts:60` | -| `getCSSAnimation` | `(target, animationOptions, trigger?)` | `Array<{ target, animation, composition?, custom?, name, keyframes, id, animationTimeline, animationRange }>` — **an array of descriptors, never a string** | `../src/api/cssAnimations.ts:51` | +| `getCSSAnimation` | `(target, animationOptions, trigger?, sequenceOptions?)` | `Array<{ target, animation, composition?, custom?, name, keyframes, id, animationTimeline, animationRange }>` — **an array of descriptors, never a string** | `../src/api/cssAnimations.ts:75` | | `getScrubScene` | `(target, animationOptions, trigger, sceneOptions?)` | `ScrubScrollScene[] \| ScrubPointerScene \| ScrubPointerScene[] \| null` | `../src/motion.ts:74` | | `getAnimation` | `(target, animationOptions, trigger?, reducedMotion?)` | `AnimationGroup \| MouseAnimationInstance \| null` | `../src/motion.ts:198` | | `prepareAnimation` | `(target, animation, callback?)` | `void` | `../src/api/prepare.ts:5` | @@ -109,7 +109,8 @@ runtime — type your consts accordingly. | `createAnimationGroups` | `(animationGroupArgs, context?)` | `AnimationGroup[]` | `../src/motion.ts:232` | | `registerEffects` | `(effects: Record)` | `void` | `../src/api/registry.ts:5` | | `getEasing` | `(easing?: string)` | `string` — CSS easing string, default `'linear'` | `../src/utils.ts:7` | -| `getJsEasing` | `(easing?: string)` | `((t: number) => number) \| undefined` | `../src/utils.ts:177` | +| `getJsEasing` | `(easing?: string)` | `((t: number) => number) \| undefined` | `../src/utils.ts:312` | +| `getJsEasingInCSS` | `(easing?: string)` | `((t: string) => string) \| undefined` — builds a `calc()` fragment, not a number | `../src/utils.ts:324` | Also exported (not detailed here): `getElementCSSAnimation`, `getElementAnimation` — look for an existing CSS animation already running on an element (used internally by `getAnimation`). @@ -129,7 +130,7 @@ do not mix them up: `cubicInOut`, `quartIn`, `quartOut`, `quartInOut`, `quintIn`, `quintOut`, `quintInOut`, `expoIn`, `expoOut`, `expoInOut`, `circIn`, `circOut`, `circInOut`, `backIn`, `backOut`, `backInOut`. -**CSS easings** (`cssEasings`, `../src/easings.ts:218-248`) — named → `cubic-bezier(...)` (or a +**CSS easings** (`cssEasings`) — named → `cubic-bezier(...)` (or a plain CSS keyword), resolved by `getEasing` for the `easing` option: `linear`, `ease`, `easeIn`, `easeOut`, `easeInOut`, plus every JS key above except @@ -141,6 +142,14 @@ falls back to the raw string if it isn't a known key, else `'linear'`. `getJsEas `undefined` only when `easing` is falsy, and otherwise falls back to `jsEasings.linear` if nothing else parses. +**CSS-expression easings** (`jsEasingsInCSS`) — a third set, mirroring +every `jsEasings` curve (plus `ease`/`easeIn`/`easeOut`/`easeInOut`) as `calc()` **string fragments** +rather than functions. Resolved by `getJsEasingInCSS`, which accepts the same inputs as `getJsEasing` +(named key, `cubic-bezier(...)`, `linear(...)`) and returns a `(t: string) => string` builder that +substitutes an arbitrary CSS expression for `t`. Used only to compile a sequence's `offsetEasing` into +the staggered `animation-delay` — see [`./css-generation.md`](./css-generation.md#sequence-stagger-in-css). +The generated expressions rely on the CSS math functions `pow`, `sqrt`, `sin`, `cos`, `acos`, `round`, `max` and `clamp`. + **Easing names that DO NOT EXIST — never use:** `easeOutCubic`, `elasticOut`, `bounceOut`, `bounceIn`. `elastic` and `bounce` **do** exist, but only as `ScrubTransitionEasing` values diff --git a/packages/motion/rules/sequences.md b/packages/motion/rules/sequences.md index 1f92c1f3..77dee485 100644 --- a/packages/motion/rules/sequences.md +++ b/packages/motion/rules/sequences.md @@ -18,6 +18,7 @@ uses it internally for staggered list animations. - [`SequenceOptions` / `AnimationGroupArgs`](#sequenceoptions--animationgroupargs) - [Target Resolution](#target-resolution) - [Stagger Offset Formula](#stagger-offset-formula) +- [CSS-Driven Stagger (`sequenceId`)](#css-driven-stagger-sequenceid) - [`Sequence` Class Surface](#sequence-class-surface) - [Reduced Motion](#reduced-motion) - [Gotchas / Rules](#gotchas--rules) @@ -65,11 +66,10 @@ type SequenceOptions = { delay?: number; // ms base delay, default 0 offset?: number; // ms stagger interval, default 0 offsetEasing?: string | ((p: number) => number); // default 'linear' + sequenceId?: string; // opts CSS-driven groups into the CSS stagger path }; ``` -(`../src/types.ts:268-272`) - ```typescript type AnimationGroupArgs = { target: HTMLElement | HTMLElement[] | string | null; @@ -103,13 +103,41 @@ offset[i] = (offsetEasing(i / last) * last * offset) | 0 ``` where `i` is the (0-based) group index and `last` is the index of the final group (`count - 1`). Single- -group sequences (`count <= 1`) always produce `[0]`, regardless of `offset`/`offsetEasing` -(`../src/Sequence.ts:52-62`). - -Each group's calculated offset is added to its animations' `delay` timing. An `endDelay` is also computed -per group so that **all groups share the same total active duration** — this is what lets `finished` / -`onFinish` resolve at the correct overall time regardless of per-group stagger -(`../src/Sequence.ts:64-102`). +group sequences (`count <= 1`) always produce `[0]`, regardless of `offset`/`offsetEasing`. + +Each group's calculated offset is added to its animations' `delay` timing, and the sequence-level +`delay` is added on top of that. An `endDelay` is also computed per group so that **all groups share the +same total active duration** — this is what lets `finished` / `onFinish` resolve at the correct overall +time regardless of per-group stagger. + +> **Rule**: the sequence-level `delay` shifts the whole timeline, so it is deliberately **excluded** from +> the `endDelay` computation — `endDelay = sequenceDuration - (baseDelay + offset[i] + duration × iterations)`. +> Folding `delay` into that subtraction produces negative `endDelay`s and breaks reverse playback. + +## CSS-Driven Stagger (`sequenceId`) + +When the child animations are **CSS Animations** (`AnimationGroup.isCSS`, i.e. picked up from +already-rendered CSS via `getElementCSSAnimation`) their `delay` comes from the generated `animation` +shorthand, not from the WAAPI. Overwriting it with `updateTiming({ delay })` would detach the animation +from its CSS declaration. So for that combination `Sequence` takes a different route: + +| Condition | How the stagger delay is applied | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| `sequenceId` set + `isCSS` | Sets `--motion--index` (the group index) and `--motion--last` on the group's target element | +| otherwise | `effect.updateTiming({ delay: baseDelay + offset[i] + sequence.delay })` | + +The matching `calc()` that consumes those custom properties is emitted by `getCSSAnimation` when it is +passed the same `SequenceOptions` — see [`./css-generation.md`](./css-generation.md#sequence-stagger-in-css). +`endDelay` is applied in **both** routes, so `finished`/`onFinish` behave identically. + +- **MUST** pass the same `sequenceId` to `getCSSAnimation` (at CSS-generation time) and to + `getSequence`/`new Sequence` (at runtime) — they are the two halves of one contract, joined only by + the custom-property name. A mismatch silently yields no stagger: the `var()` fallbacks resolve + `index` to `0` and every element animates at the base delay. +- **Rule**: a `Sequence` without a `sequenceId`, or one whose groups are WAAPI animations, behaves exactly + as before — the CSS path is purely additive. +- **Rule**: the target must be an `HTMLElement` for the custom properties to be set; other targets fall + through with `endDelay` applied but no stagger. ### Offsets by Easing @@ -158,6 +186,7 @@ class Sequence extends AnimationGroup { delay: number; offset: number; offsetEasing: (p: number) => number; + sequenceId: string | undefined; constructor(animationGroups: AnimationGroup[], options?: SequenceOptions); @@ -182,22 +211,18 @@ class Sequence extends AnimationGroup { } ``` -(`../src/Sequence.ts:13-45`) - ```typescript type IndexedGroup = { index: number; group: AnimationGroup }; ``` -(`../src/types.ts:280-283`) - -- **`addGroups(entries)`** (`../src/Sequence.ts:109-128`) — inserts groups at the given indices (processed +- **`addGroups(entries)`** — inserts groups at the given indices (processed highest-index-first so earlier insertion indices stay valid), splices the new groups' animations into the flattened `animations` array at the matching position, recalculates offsets for **all** groups, and resets `ready` to `Promise.all(animationGroups.map(g => g.ready))`. -- **`removeGroups(predicate)`** (`../src/Sequence.ts:135-163`) — cancels and removes every group for which +- **`removeGroups(predicate)`** — cancels and removes every group for which `predicate(group)` returns `true`, rebuilds the flattened `animations` array, recalculates offsets for the remaining groups, resets `ready`, and returns the removed groups (`[]` if none matched). -- **`onFinish(callback)`** (overridden, `../src/Sequence.ts:165-172`) — awaits each child group's own +- **`onFinish(callback)`** (overridden) — awaits each child group's own `finished` promise individually (`Promise.all(animationGroups.map(g => g.finished))`), not the flattened `AnimationGroup.finished`. On any rejection it logs a warning via `console.warn` and does **not** invoke `callback`. @@ -205,14 +230,14 @@ type IndexedGroup = { index: number; group: AnimationGroup }; can be read back, but mutating them after construction does **not** retrigger offset recalculation — `applyOffsets()` is private and only runs from the constructor, `addGroups`, and `removeGroups`. To change stagger timing, construct a new `Sequence`. -- **`offsetEasing` resolution** (`../src/Sequence.ts:27-30`): if `options.offsetEasing` is a function, it's +- **`offsetEasing` resolution**: if `options.offsetEasing` is a function, it's used as-is; if it's a string, it's resolved via `getJsEasing(string)`; otherwise (or if resolution fails) it falls back to the local `linear` easing. Valid string keys are the `jsEasings` set — `linear, sineIn, sineOut, sineInOut, quadIn, quadOut, quadInOut, cubicIn, cubicOut, cubicInOut, quartIn, quartOut, quartInOut, quintIn, quintOut, quintInOut, expoIn, expoOut, expoInOut, circIn, circOut, circInOut, backIn, backOut, backInOut` (`../src/easings.ts:187-213`) — or a raw `cubic-bezier(x1, y1, x2, y2)` string, or a custom - `(p: number) => number` function (`../src/utils.ts:177-187`). + `(p: number) => number` function. ### `addGroups` / `removeGroups` Examples diff --git a/packages/motion/src/Sequence.ts b/packages/motion/src/Sequence.ts index e05c8139..f5d442b3 100644 --- a/packages/motion/src/Sequence.ts +++ b/packages/motion/src/Sequence.ts @@ -15,6 +15,7 @@ export class Sequence extends AnimationGroup { delay: number; offset: number; offsetEasing: (p: number) => number; + sequenceId: string | undefined; private timingOptions: { delay: number; duration: number; iterations: number }[][]; constructor(animationGroups: AnimationGroup[], options: SequenceOptions = {}) { @@ -24,6 +25,7 @@ export class Sequence extends AnimationGroup { this.animationGroups = animationGroups; this.delay = options.delay ?? 0; this.offset = options.offset ?? 0; + this.sequenceId = options.sequenceId; this.offsetEasing = typeof options.offsetEasing === 'function' ? options.offsetEasing @@ -67,6 +69,7 @@ export class Sequence extends AnimationGroup { const offsets = this.calculateOffsets(); const sequenceDuration = this.getSequenceActiveDuration(offsets); + const last = this.animationGroups.length - 1; this.animationGroups.forEach((group, groupIdx) => { group.animations.forEach((animation, animIdx) => { const effect = animation.effect; @@ -77,8 +80,19 @@ export class Sequence extends AnimationGroup { const delay = baseDelay + offsets[groupIdx]; const endDelay = sequenceDuration - (delay + duration * iterations); - // add the sequence delay to the animation delay at the end - it doesn't need to affect the endDelay - effect.updateTiming({ delay: delay + this.delay, endDelay }); + if (group.isCSS && !!this.sequenceId) { + const { target } = effect as KeyframeEffect; + + if (target instanceof HTMLElement) { + target.style.setProperty(`--motion-${this.sequenceId}-index`, `${groupIdx}`); + target.style.setProperty(`--motion-${this.sequenceId}-last`, `${last}`); + } + + effect.updateTiming({ endDelay }); + } else { + // add the sequence delay to the animation delay at the end - it doesn't need to affect the endDelay + effect.updateTiming({ delay: delay + this.delay, endDelay }); + } }); }); } diff --git a/packages/motion/src/api/cssAnimations.ts b/packages/motion/src/api/cssAnimations.ts index 4d61acf0..6aa78dd3 100644 --- a/packages/motion/src/api/cssAnimations.ts +++ b/packages/motion/src/api/cssAnimations.ts @@ -3,8 +3,10 @@ import type { AnimationDataForScrub, AnimationEffectAPI, AnimationOptions, + SequenceOptions, TriggerVariant, } from '../types'; +import { getJsEasingInCSS } from '../utils'; import { getEffectsData, getRanges, getNamedEffect, isNotAScrubTrigger } from './common'; function getAnimationTarget(target: string | null, part: string | undefined) { @@ -19,13 +21,35 @@ function getAnimationAsCSS( part: string | undefined; }, isRunning?: boolean, + sequenceOptions?: SequenceOptions, ) { const { duration, delay, iterations = 1, fill, easing = 'linear', direction } = data.options; const animationName = data.effect.name; const isAutoDuration = duration === 'auto'; + const { sequenceId, delay: seqDelay, offset, offsetEasing = 'linear' } = sequenceOptions || {}; + let delayStr = `${delay ?? 0}ms`; + + if (sequenceId && typeof offsetEasing === 'string') { + const calcEasing = getJsEasingInCSS(offsetEasing); + + if (calcEasing) { + const baseDelay = (delay ?? 0) + (seqDelay ?? 0); + + if (offset) { + const easing = calcEasing( + `(var(--motion-${sequenceId}-index, 0) / var(--motion-${sequenceId}-last, 1))`, + ); + const stagger = `(${baseDelay} + ${easing} * ${offset} * var(--motion-${sequenceId}-last, 1))`; + delayStr = `calc(${stagger} * 1ms)`; + } else { + delayStr = `${baseDelay}ms`; + } + } + } + return `${animationName} ${isAutoDuration ? 'auto' : `${duration}ms`}${ - isAutoDuration ? ' ' : ` ${delay ?? 0}ms ` + isAutoDuration ? ' ' : ` ${delayStr} ` }${easing}${fill && fill !== 'none' ? ` ${fill}` : ''} ${ !iterations || iterations === Infinity ? 'infinite' : iterations }${direction === 'normal' ? '' : ` ${direction}`} ${isRunning ? '' : 'paused'}`; @@ -52,6 +76,7 @@ function getCSSAnimation( target: string | null, animationOptions: AnimationOptions, trigger?: TriggerVariant, + sequenceOptions?: SequenceOptions, ) { // get the preset for the given animation options const namedEffect = getNamedEffect(animationOptions) as AnimationEffectAPI | null; @@ -67,7 +92,7 @@ function getCSSAnimation( return { target: getAnimationTarget(target, item.part), - animation: getAnimationAsCSS(item, isViewProgress), + animation: getAnimationAsCSS(item, isViewProgress, sequenceOptions), composition: item.options.composite, custom: item.effect.custom, name: item.effect.name, diff --git a/packages/motion/src/easings.ts b/packages/motion/src/easings.ts index 378ab3fe..3cd40a84 100644 --- a/packages/motion/src/easings.ts +++ b/packages/motion/src/easings.ts @@ -212,6 +212,99 @@ export const jsEasings = { backInOut, }; +export const cubicBezierCalc = (t: string, x1: number, y1: number, x2: number, y2: number) => { + const cx1 = 3 * x1; + const cx2 = 3 * (x2 - 2 * x1); + const cx3 = 1 - 3 * x2 + 3 * x1; + const cy1 = 3 * y1; + const cy2 = 3 * (y2 - 2 * y1); + const cy3 = 1 - 3 * y2 + 3 * y1; + + let t_val: string; + + if (cx3 === 0) { + // degenerate curve - x(s) is quadratic (or linear when cx2 is also 0), not cubic + t_val = + cx2 === 0 + ? `(${t} / ${cx1})` + : `((sqrt(max(0, ${cx1 * cx1} + ${4 * cx2} * ${t})) - ${cx1}) / ${2 * cx2})`; + } else { + const shift = cx2 / (3 * cx3); + const qNum = 2 * Math.pow(cx2, 3) - 9 * cx1 * cx2 * cx3; + const qDen = 54 * Math.pow(cx3, 3); + const p_3 = (3 * cx3 * cx1 - cx2 * cx2) / (9 * cx3 * cx3); + const q_2 = `((${qNum} - ${27 * cx3 * cx3} * ${t}) / ${qDen})`; + + if (p_3 < 0) { + // casus irreducibilis - Cardano's cube roots would be complex here, so use the trigonometric form + const m = 2 * Math.sqrt(-p_3); + + let rootIdx = 0; + for (const k of [0, 1, 2]) { + const inDomain = [0, 0.25, 0.5, 0.75, 1].every((t) => { + const arg = Math.min( + Math.max((2 * (qNum - 27 * cx3 * cx3 * t)) / (qDen * p_3 * m), -1), + 1, + ); + const root = m * Math.cos((Math.acos(arg) - k * 2 * Math.PI) / 3) - shift; + + return root >= -1e-6 && root <= 1 + 1e-6; + }); + + if (inDomain) rootIdx = k; + } + // clamping guards acos against arguments pushed just outside [-1, 1] by float error + const angle = `(acos(clamp(-1, ${2 / (p_3 * m)} * ${q_2}, 1)) - ${360 * rootIdx}deg) / 3`; + + t_val = `(${m} * cos(${angle}) - ${shift})`; + } else { + const sqrt = `sqrt(pow(${q_2}, 2) + ${Math.pow(p_3, 3)})`; + t_val = `(pow(${sqrt} - ${q_2}, 1 / 3) - pow(${sqrt} + ${q_2}, 1 / 3) - ${shift})`; + } + } + + return `(${cy3} * pow(${t_val}, 3) + ${cy2} * pow(${t_val}, 2) + ${cy1} * ${t_val})`; +}; + +export const jsEasingsInCSS = { + linear: (t: string) => t, + ease: (t: string) => cubicBezierCalc(t, 0.25, 0.1, 0.25, 1), + easeIn: (t: string) => cubicBezierCalc(t, 0.42, 0, 1, 1), + easeOut: (t: string) => cubicBezierCalc(t, 0, 0, 0.58, 1), + easeInOut: (t: string) => cubicBezierCalc(t, 0.42, 0, 0.58, 1), + sineIn: (t: string) => `(1 - cos(${t} * 90deg))`, + sineOut: (t: string) => `(sin(${t} * 90deg))`, + sineInOut: (t: string) => `((1 - cos(${t} * 180deg)) / 2)`, + quadIn: (t: string) => `(${t} * ${t})`, + quadOut: (t: string) => `(1 - (1 - ${t}) * (1 - ${t}))`, + quadInOut: (t: string) => + `(round(${t}) * (1 - (-2 * ${t} + 2) * (-2 * ${t} + 2) / 2) + (1 - round(${t})) * 2 * ${t} * ${t})`, + cubicIn: (t: string) => `pow(${t}, 3)`, + cubicOut: (t: string) => `(1 - pow(1 - ${t}, 3))`, + cubicInOut: (t: string) => + `(round(${t}) * (1 - pow(-2 * ${t} + 2, 3) / 2) + (1 - round(${t})) * 4 * pow(${t}, 3))`, + quartIn: (t: string) => `pow(${t}, 4)`, + quartOut: (t: string) => `(1 - pow(1 - ${t}, 4))`, + quartInOut: (t: string) => + `(round(${t}) * (1 - pow(-2 * ${t} + 2, 4) / 2) + (1 - round(${t})) * 8 * pow(${t}, 4))`, + quintIn: (t: string) => `pow(${t}, 5)`, + quintOut: (t: string) => `(1 - pow(1 - ${t}, 5))`, + quintInOut: (t: string) => + `(round(${t}) * (1 - pow(-2 * ${t} + 2, 5) / 2) + (1 - round(${t})) * 16 * pow(${t}, 5))`, + expoIn: (t: string) => `(pow(2, 10 * ${t} - 10) - pow(2, -10) * (1 - ${t}))`, + expoOut: (t: string) => `(1 - pow(2, -10 * ${t}) + pow(2, -10) * ${t})`, + expoInOut: (t: string) => + `((round(${t}) * (2 - pow(2, -20 * ${t} + 10)) + (1 - round(${t})) * pow(2, 20 * ${t} - 10)) / 2)`, + circIn: (t: string) => `(1 - sqrt(1 - ${t} * ${t}))`, + circOut: (t: string) => `sqrt(1 - (${t} - 1) * (${t} - 1))`, + circInOut: (t: string) => + `((round(${t}) * (sqrt(max(0, (3 - 2 * ${t}) * (2 * ${t} - 1))) + 1) + (1 - round(${t})) * (1 - sqrt(max(0, 1 - 4 * ${t} * ${t})))) / 2)`, + backIn: (t: string) => `(2.70158 * pow(${t}, 3) - 1.70158 * ${t} * ${t})`, + backOut: (t: string) => `(1 + 2.70158 * pow(${t} - 1, 3) + 1.70158 * (${t} - 1) * (${t} - 1))`, + backInOut: (t: string) => + `((round(${t}) * (2 + (2 * ${t} - 2) * (2 * ${t} - 2) * (2.5949095 + 3.5949095 * (2 * ${t} - 2))) + (1 - round(${t})) * 4 * ${t} * ${t} * (3.5949095 * 2 * ${t} - 2.5949095)) / 2)`, +}; + /** * CSS cubic-bezier easings based on PostCSS Easings */ diff --git a/packages/motion/src/types.ts b/packages/motion/src/types.ts index 97c82db3..0718e171 100644 --- a/packages/motion/src/types.ts +++ b/packages/motion/src/types.ts @@ -269,6 +269,7 @@ export type SequenceOptions = { delay?: number; offset?: number; offsetEasing?: string | ((p: number) => number); + sequenceId?: string; }; export type AnimationGroupArgs = { diff --git a/packages/motion/src/utils.ts b/packages/motion/src/utils.ts index 3f38e72b..2f4a06c9 100644 --- a/packages/motion/src/utils.ts +++ b/packages/motion/src/utils.ts @@ -1,4 +1,4 @@ -import { cssEasings, jsEasings } from './easings'; +import { cssEasings, jsEasings, jsEasingsInCSS, cubicBezierCalc } from './easings'; export function getCssUnits(unit: 'percentage' | string) { return unit === 'percentage' ? '%' : unit || 'px'; @@ -100,7 +100,12 @@ export function getEasing(easing?: keyof typeof cssEasings | string): string { return easing ? cssEasings[easing as keyof typeof cssEasings] || easing : cssEasings.linear; } -function cubicBezierEasing(x1: number, y1: number, x2: number, y2: number): (t: number) => number { +export function cubicBezierEasing( + x1: number, + y1: number, + x2: number, + y2: number, +): (t: number) => number { const cx = 3 * x1; const bx = 3 * (x2 - x1) - cx; const ax = 1 - cx - bx; @@ -149,7 +154,7 @@ function cubicBezierEasing(x1: number, y1: number, x2: number, y2: number): (t: }; } -function parseCubicBezier(str: string): ((t: number) => number) | undefined { +function parseCubicBezierParams(str: string): [number, number, number, number] | undefined { const m = str.match( /^cubic-bezier\(\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*\)$/, ); @@ -163,10 +168,26 @@ function parseCubicBezier(str: string): ((t: number) => number) | undefined { if ([x1, y1, x2, y2].some(isNaN)) return undefined; - return cubicBezierEasing(x1, y1, x2, y2); + return [x1, y1, x2, y2]; } -function parseCssLinear(str: string): ((t: number) => number) | undefined { +function parseCubicBezier(str: string): ((t: number) => number) | undefined { + const params = parseCubicBezierParams(str); + + if (!params || params.some(isNaN)) return undefined; + + return cubicBezierEasing(...params); +} + +function parseCubicBezierToCalc(str: string): ((t: string) => string) | undefined { + const params = parseCubicBezierParams(str); + + if (!params || params.some(isNaN)) return undefined; + + return (t) => cubicBezierCalc(t, ...params); +} + +function parseCssLinearStops(str: string): Array<{ output: number; pos: number }> | undefined { const m = str.match(/^linear\((.+)\)$/); if (!m) return undefined; @@ -240,7 +261,12 @@ function parseCssLinear(str: string): ((t: number) => number) | undefined { if (stops[j].pos! < stops[j - 1].pos!) stops[j].pos = stops[j - 1].pos; } - const resolved = stops as Array<{ output: number; pos: number }>; + return stops as Array<{ output: number; pos: number }>; +} + +function parseCssLinear(str: string): ((t: number) => number) | undefined { + const resolved = parseCssLinearStops(str); + if (!resolved) return undefined; return (t: number) => { if (t <= resolved[0].pos) return resolved[0].output; @@ -266,6 +292,30 @@ function parseCssLinear(str: string): ((t: number) => number) | undefined { }; } +function parseCssLinearToCalc(str: string): ((t: string) => string) | undefined { + const resolved = parseCssLinearStops(str); + if (!resolved) return undefined; + + return (t: string) => { + const terms: string[] = [`${resolved[0].output}`]; + for (let i = 1; i < resolved.length; i++) { + const dx = resolved[i].pos - resolved[i - 1].pos; + const dy = resolved[i].output - resolved[i - 1].output; + + if (dx === 0 && resolved[i - 1].pos === 0) { + terms.push(`${dy}`); + } else { + const clampMid = + dx === 0 + ? `round(${t} / ${2 * resolved[i - 1].pos})` + : `(${t} - ${resolved[i - 1].pos}) / ${dx}`; + terms.push(`clamp(0, ${clampMid}, 1) * ${dy}`); + } + } + return `(${terms.join(' + ')})`; + }; +} + export function getJsEasing( easing?: keyof typeof jsEasings | string, ): ((t: number) => number) | undefined { @@ -277,3 +327,15 @@ export function getJsEasing( return parseCubicBezier(easing) ?? parseCssLinear(easing) ?? jsEasings.linear; } + +export function getJsEasingInCSS( + easing?: keyof typeof jsEasingsInCSS | string, +): ((t: string) => string) | undefined { + if (!easing) return undefined; + + const named = jsEasingsInCSS[easing as keyof typeof jsEasingsInCSS]; + + if (named) return named; + + return parseCubicBezierToCalc(easing) ?? parseCssLinearToCalc(easing) ?? jsEasingsInCSS.linear; +} diff --git a/packages/motion/test/Sequence.spec.ts b/packages/motion/test/Sequence.spec.ts index 70084ad4..af742e5c 100644 --- a/packages/motion/test/Sequence.spec.ts +++ b/packages/motion/test/Sequence.spec.ts @@ -84,6 +84,14 @@ function createStatefulGroup(duration = 1000, initialDelay = 0) { return new AnimationGroup([anim]); } +function createCSSGroup(target: HTMLElement) { + const anim = createStatefulMockAnimation(1000, 0); + Object.setPrototypeOf(anim, (globalThis as any).CSSAnimation.prototype); + (anim.effect as any).target = target; + + return new AnimationGroup([anim as Animation]); +} + describe('Sequence', () => { describe('Constructor', () => { test('creates Sequence with empty groups array', () => { @@ -273,6 +281,73 @@ describe('Sequence', () => { }); }); + describe('CSS-driven stagger (sequenceId + CSS groups)', () => { + const createTargets = (count: number) => + Array.from({ length: count }, () => document.createElement('div')); + + test('sets the index/last custom properties on each group target', () => { + const targets = createTargets(3); + const groups = targets.map((t) => createCSSGroup(t)); + new Sequence(groups, { sequenceId: 'seq-0-0', offset: 100, offsetEasing: 'linear' }); + + targets.forEach((target, i) => { + expect(target.style.getPropertyValue('--motion-seq-0-0-index')).toBe(`${i}`); + expect(target.style.getPropertyValue('--motion-seq-0-0-last')).toBe('2'); + }); + }); + + test('does not override the CSS-driven delay via updateTiming', () => { + const targets = createTargets(3); + const groups = targets.map((t) => createCSSGroup(t)); + new Sequence(groups, { sequenceId: 'seq-0-0', delay: 500, offset: 100 }); + + groups.forEach((group) => { + expect(group.animations[0].effect!.getTiming().delay).toBe(0); + expect(group.animations[0].effect!.updateTiming).not.toHaveBeenCalledWith( + expect.objectContaining({ delay: expect.anything() }), + ); + }); + }); + + test('still computes endDelay so all groups share one timeline', () => { + const targets = createTargets(3); + const groups = targets.map((t) => createCSSGroup(t)); + new Sequence(groups, { sequenceId: 'seq-0-0', offset: 100, offsetEasing: 'linear' }); + + const endDelays = groups.map((g) => g.animations[0].effect!.getTiming().endDelay); + expect(endDelays).toEqual([200, 100, 0]); + }); + + test('recalculates the index properties after addGroups', () => { + const targets = createTargets(2); + const groups = targets.map((t) => createCSSGroup(t)); + const sequence = new Sequence(groups, { sequenceId: 'seq-0-0', offset: 100 }); + + const newTarget = document.createElement('div'); + sequence.addGroups([{ index: 1, group: createCSSGroup(newTarget) }]); + + expect(newTarget.style.getPropertyValue('--motion-seq-0-0-index')).toBe('1'); + expect(targets[1].style.getPropertyValue('--motion-seq-0-0-index')).toBe('2'); + expect(targets[0].style.getPropertyValue('--motion-seq-0-0-last')).toBe('2'); + }); + + test('falls back to updateTiming for CSS groups when the sequence has no id', () => { + const targets = createTargets(2); + const groups = targets.map((t) => createCSSGroup(t)); + new Sequence(groups, { offset: 100, offsetEasing: 'linear' }); + + expect(groups.map((g) => g.animations[0].effect!.getTiming().delay)).toEqual([0, 100]); + expect(targets[0].style.getPropertyValue('--motion-undefined-index')).toBe(''); + }); + + test('falls back to updateTiming for non-CSS groups even when a sequenceId is set', () => { + const groups = [createStatefulGroup(), createStatefulGroup()]; + new Sequence(groups, { sequenceId: 'seq-0-0', offset: 100, offsetEasing: 'linear' }); + + expect(groups.map((g) => g.animations[0].effect!.getTiming().delay)).toEqual([0, 100]); + }); + }); + describe('endDelay for reverse playback', () => { test('computes endDelay so all animations share the same total timeline', () => { const groups = [createStatefulGroup(), createStatefulGroup(), createStatefulGroup()]; diff --git a/packages/motion/test/cssEasings.spec.ts b/packages/motion/test/cssEasings.spec.ts new file mode 100644 index 00000000..b12b946f --- /dev/null +++ b/packages/motion/test/cssEasings.spec.ts @@ -0,0 +1,116 @@ +import { describe, expect, test } from 'vitest'; +import { cubicBezierCalc, jsEasings, jsEasingsInCSS } from '../src/easings'; +import { cubicBezierEasing, getJsEasing, getJsEasingInCSS } from '../src/utils'; + +/** + * `jsEasingsInCSS` emits `calc()` expression fragments, so asserting on the strings would only + * pin down their spelling. Instead these tests translate the CSS math functions to their JS + * equivalents and check that the expression *evaluates* to the same curve as the JS easing it + * mirrors - which is what the staggered-sequence CSS actually depends on. + */ +function evaluateCSSCalc(expression: string): number { + const body = expression + .replace(/(-?[\d.]+)deg/g, '($1 * Math.PI / 180)') + .replace(/\bpow\(/g, 'Math.pow(') + .replace(/\bsqrt\(/g, 'Math.sqrt(') + .replace(/\bacos\(/g, 'Math.acos(') + .replace(/\bsin\(/g, 'Math.sin(') + .replace(/\bcos\(/g, 'Math.cos(') + .replace(/\bround\(/g, 'Math.round(') + .replace(/\bmax\(/g, 'Math.max(') + .replace(/\bclamp\(/g, 'clamp('); + + return new Function('clamp', `"use strict"; return ${body};`)( + (lo: number, value: number, hi: number) => Math.min(Math.max(value, lo), hi), + ); +} + +const at = (build: (t: string) => string, t: number) => evaluateCSSCalc(build(`(${t})`)); + +const SAMPLES = [0, 0.1, 0.25, 1 / 3, 0.4, 0.5, 0.6, 2 / 3, 0.75, 0.9, 1]; + +describe('easings/jsEasingsInCSS', () => { + const named = Object.keys(jsEasingsInCSS).filter( + (name) => name in jsEasings, + ) as (keyof typeof jsEasings & keyof typeof jsEasingsInCSS)[]; + + test('covers every JS easing so any offsetEasing can be staggered in CSS', () => { + expect(named.length).toBe(Object.keys(jsEasings).length); + }); + + test.each(named)('%s matches the JS easing across the curve', (name) => { + for (const t of SAMPLES) { + // expo* trade the JS `t === 0`/`t === 1` special cases for a branchless linear correction + expect(at(jsEasingsInCSS[name], t)).toBeCloseTo( + jsEasings[name](t), + name.startsWith('expo') ? 2 : 6, + ); + } + }); +}); + +describe('easings/cubicBezierCalc', () => { + const curves: [string, [number, number, number, number]][] = [ + ['ease', [0.25, 0.1, 0.25, 1]], + ['easeIn', [0.42, 0, 1, 1]], + ['easeOut', [0, 0, 0.58, 1]], + ['easeInOut', [0.42, 0, 0.58, 1]], + ['overshoot', [0.68, -0.55, 0.265, 1.55]], + ['expo-like', [0.16, 1, 0.3, 1]], + ['extreme', [1, 0, 0, 1]], + ['degenerate quadratic', [1 / 3, 0, 2 / 3, 1]], + ]; + + test.each(curves)('%s resolves to finite values matching the reference curve', (_name, curve) => { + const reference = cubicBezierEasing(...curve); + + for (const t of SAMPLES) { + const value = at((input) => cubicBezierCalc(input, ...curve), t); + + expect(Number.isFinite(value)).toBe(true); + expect(value).toBeCloseTo(reference(t), 6); + } + }); +}); + +describe('utils/getJsEasingInCSS()', () => { + test('returns undefined for falsy easing', () => { + expect(getJsEasingInCSS()).toBeUndefined(); + expect(getJsEasingInCSS('')).toBeUndefined(); + }); + + test('resolves a named easing', () => { + expect(at(getJsEasingInCSS('quadIn')!, 0.5)).toBeCloseTo(0.25, 6); + }); + + test('passes the raw index expression through for linear', () => { + expect(getJsEasingInCSS('linear')!('var(--i)')).toBe('var(--i)'); + }); + + test('parses a cubic-bezier() string', () => { + const easing = getJsEasingInCSS('cubic-bezier(0.25, 0.1, 0.25, 1)')!; + + expect(at(easing, 0)).toBeCloseTo(0, 6); + expect(at(easing, 1)).toBeCloseTo(1, 6); + expect(at(easing, 0.5)).toBeCloseTo(getJsEasing('cubic-bezier(0.25, 0.1, 0.25, 1)')!(0.5), 6); + }); + + test.each([ + 'linear(0, 1)', + 'linear(0, 0.5 50%, 1)', + 'linear(0.2, 0.8)', + 'linear(0, 1 40% 60%, 0)', + 'linear(0, 1 50% 50%, 0)', + ])('parses %s to the same curve as getJsEasing', (input) => { + const css = getJsEasingInCSS(input)!; + const js = getJsEasing(input)!; + + for (const t of SAMPLES) { + expect(at(css, t)).toBeCloseTo(js(t), 6); + } + }); + + test('falls back to linear for an unparsable easing', () => { + expect(getJsEasingInCSS('not-a-real-easing')).toBe(jsEasingsInCSS.linear); + }); +}); diff --git a/packages/motion/test/motion.spec.ts b/packages/motion/test/motion.spec.ts index 371a6852..1b5e2b1c 100644 --- a/packages/motion/test/motion.spec.ts +++ b/packages/motion/test/motion.spec.ts @@ -363,6 +363,78 @@ describe('motion.ts', () => { expect(result[0].animation).toContain('200ms'); }); + describe('sequence stagger', () => { + const staggered = ( + sequenceOptions: Parameters[3], + options: Partial = {}, + ) => + getCSSAnimation( + 'test-target', + { namedEffect: { type: 'FadeIn', id: 'fade' }, duration: 1000, ...options }, + undefined, + sequenceOptions, + )[0].animation; + + test('should emit a plain delay when no sequence options are passed', () => { + expect(staggered(undefined, { delay: 200 })).toContain(' 200ms '); + }); + + test('should emit a calc() delay driven by the sequence index custom properties', () => { + const animation = staggered({ sequenceId: 'seq-0-0', offset: 100 }); + + expect(animation).toContain('calc('); + expect(animation).toContain('var(--motion-seq-0-0-index, 0)'); + expect(animation).toContain('var(--motion-seq-0-0-last, 1)'); + expect(animation).toContain('* 1ms'); + }); + + test('should fold the effect delay and the sequence delay into the calc() base', () => { + const animation = staggered( + { sequenceId: 'seq-0-0', offset: 100, delay: 50 }, + { delay: 200 }, + ); + + expect(animation).toContain('calc((250 +'); + }); + + test('should emit a plain summed delay when the sequence has no offset', () => { + const animation = staggered( + { sequenceId: 'seq-0-0', offset: 0, delay: 50 }, + { delay: 200 }, + ); + + expect(animation).toContain(' 250ms '); + expect(animation).not.toContain('calc('); + }); + + test('should apply the offsetEasing to the index ratio', () => { + const animation = staggered({ + sequenceId: 'seq-0-0', + offset: 100, + offsetEasing: 'quadIn', + }); + const ratio = '(var(--motion-seq-0-0-index, 0) / var(--motion-seq-0-0-last, 1))'; + + expect(animation).toContain(`${ratio} * ${ratio}`); + }); + + test('should ignore a function offsetEasing - it cannot be expressed in CSS', () => { + const animation = staggered({ + sequenceId: 'seq-0-0', + offset: 100, + offsetEasing: (p: number) => p ** 2, + delay: 50, + }); + + expect(animation).not.toContain('calc('); + expect(animation).toContain(' 0ms '); + }); + + test('should not stagger when the sequence has no id', () => { + expect(staggered({ offset: 100, delay: 50 }, { delay: 200 })).toContain(' 200ms '); + }); + }); + test('should handle named effects', () => { const animationOptions: AnimationOptions = { namedEffect: { diff --git a/skills/interactor/references/config-schema.md b/skills/interactor/references/config-schema.md index e1f87409..02a79fe7 100644 --- a/skills/interactor/references/config-schema.md +++ b/skills/interactor/references/config-schema.md @@ -254,7 +254,7 @@ type SequenceConfig = { delay?: number; // ms before the sequence starts offset?: number; // ms between each child's start offsetEasing?: string | ((p: number) => number); // stagger distribution curve (default 'linear') - sequenceId?: string; // auto-generated if omitted + sequenceId?: string; // defaults to `seq--` conditions?: string[]; triggerType?: 'once' | 'repeat' | 'alternate' | 'state'; // set on the sequence, NOT its child effects }; @@ -268,6 +268,10 @@ type SequenceConfigRef = { }; ``` +Prefer a **string** `offsetEasing`. `generate()` compiles the stagger into a `calc()` delay driven by +`--motion--index` custom properties, and a `(p: number) => number` function has no CSS form — +such a sequence is dropped from the generated CSS and loses FOUC prevention (it still runs at runtime). The validator flags this as `FUNCTION_OFFSET_EASING` (warning). + A common pattern: one trigger fires a sequence whose single effect uses `selector` to pick the items — each matched element becomes a staggered child. (Use `selector` here, not `listContainer`: one trigger fanning across many targets is the `selector` diff --git a/skills/interactor/references/motion-engine.md b/skills/interactor/references/motion-engine.md index 2248e22f..4f42c41d 100644 --- a/skills/interactor/references/motion-engine.md +++ b/skills/interactor/references/motion-engine.md @@ -113,10 +113,16 @@ seq.play(); `offset[i] = offsetEasing(i / last) * last * offsetMs`; the sequence rewrites each group's delay so all end together. +Pass a `sequenceId` and the stagger switches routes for **CSS-backed** groups: instead of rewriting +`delay`, the sequence sets `--motion--index` / `--motion--last` on each target, +which the `calc()` delay emitted by `getCSSAnimation(target, options, trigger, sequenceOptions)` reads. +This is how one static rule staggers a whole list. The same id must reach both halves; a string +`offsetEasing` is required (functions have no CSS form). + ## Easings — three separate namespaces (don't mix) 1. **`easing`** (time/scrub option) → `cssEasings`: `linear, ease, easeIn/Out/InOut, sineIn/Out/InOut, quadIn…, cubicIn…, quartIn…, quintIn…, expoIn…, circIn…, backIn/Out/InOut`, or any raw CSS `cubic-bezier(...)`/`linear(...)`. Unknown strings pass through unchanged (so a typo silently does nothing). -2. **`offsetEasing`** (sequence) → `jsEasings` function map, or a `cubic-bezier(...)`/`linear(...)` string; falls back to `linear`. +2. **`offsetEasing`** (sequence) → `jsEasings` function map, or a `cubic-bezier(...)`/`linear(...)` string; falls back to `linear`. For generated CSS it is instead compiled to a `calc()` fragment via `jsEasingsInCSS`/`getJsEasingInCSS` — same key set, but a function value is not supported there. 3. **`transitionEasing`** (scrub smoothing) → `'linear' | 'hardBackOut' | 'easeOut' | 'elastic' | 'bounce'` (note: `hardBackOut`/`elastic`/`bounce` fall back to `linear` internally). ## Engine gotchas diff --git a/skills/interactor/references/validate.md b/skills/interactor/references/validate.md index 7d82c7bf..72736944 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 (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`) +- **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`, and `FUNCTION_OFFSET_EASING` when a sequence's function easing keeps it out of the generated CSS) - **`overflow: clip`** — ancestors with `overflow: hidden` break `viewProgress` ---