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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/interact-validate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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'`).

Expand Down Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions packages/interact-validate/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const RULE_CODE_MAP: Record<string, string> = {
POINTER_AXIS_IGNORED: 'POINTER_AXIS',
INVALID_CSS_PROPERTY_NAME: 'CSS_PROPERTY_NAME',
INVALID_INSET: 'VIEW_INSET',
FUNCTION_OFFSET_EASING: 'OFFSET_EASING',
};

export function finalize(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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));
},
});

Expand Down
20 changes: 20 additions & 0 deletions packages/interact-validate/src/semantic/cssGeneration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { Path, SemanticIssue, AnySequence } from '../types';

// `generate()` compiles a sequence's stagger into a `calc()` delay driven by
// `--motion-<sequenceId>-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(...)`.',
},
];
}
1 change: 1 addition & 0 deletions packages/interact-validate/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export type AnyEffect = {
export type AnySequence = {
triggerType?: string;
sequenceId?: string;
offsetEasing?: string | ((...args: unknown[]) => unknown);
effects?: AnyEffect[];
conditions?: string[];
};
Expand Down
127 changes: 127 additions & 0 deletions packages/interact-validate/test/rules/offsetEasing.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
4 changes: 2 additions & 2 deletions packages/interact/docs/api/types.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<sequenceId>-index` custom properties that carry the stagger in generated CSS. Defaults to `seq-<interactionIndex>-<sequenceIndex>`, 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'`.

Expand Down
4 changes: 4 additions & 0 deletions packages/interact/docs/examples/list-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
35 changes: 34 additions & 1 deletion packages/interact/docs/guides/sequences.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 + <offsetEasing(index / last) > * 80 * var(--motion-card-stagger-last, 1)) * 1ms) …;
```

At runtime the `Sequence` writes `--motion-<sequenceId>-index` and `--motion-<sequenceId>-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-<interactionIndex>-<sequenceIndex>`) 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:
Expand Down Expand Up @@ -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`):
Expand Down
6 changes: 4 additions & 2 deletions packages/interact/rules/full-lean.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<interactionIdx>-<sequenceIdx>`
conditions?: string[]; // ids referencing the top-level conditions map
}
```
Expand Down
9 changes: 9 additions & 0 deletions packages/interact/rules/integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<sequenceId>-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-<interactionIndex>-<sequenceIndex>`, derived
from the config position — but a hand-written id must be stable across CSS generation and runtime.

---

## CSS Generation & FOUC Prevention
Expand Down
Loading
Loading