Skip to content

Commit c511cd5

Browse files
authored
implementing staggered sequences in css (#289)
* implementing staggered sequences in css * adding tests and docs * validations * final touches
1 parent 212efe8 commit c511cd5

42 files changed

Lines changed: 1212 additions & 128 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/interact-validate/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ const ExperienceSchema = z.object({
125125
});
126126
```
127127

128-
> `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`.
128+
> `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`.
129129
130130
## Severity model
131131

@@ -149,6 +149,7 @@ Every issue is `'error'` or `'warning'`. `valid` is `true` **iff** no `'error'`
149149
| `POINTER_AXIS` | `POINTER_AXIS_IGNORED` | warning |
150150
| `CSS_PROPERTY_NAME` | `INVALID_CSS_PROPERTY_NAME` | warning |
151151
| `VIEW_INSET` | `INVALID_INSET` | warning |
152+
| `OFFSET_EASING` | `FUNCTION_OFFSET_EASING` | warning |
152153

153154
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'`).
154155

@@ -214,6 +215,7 @@ These encode statically-detectable authoring pitfalls from the trigger rule file
214215
| `POINTER_AXIS_IGNORED` | `pointerMove` `params.axis` set on a `namedEffect`/`customEffect` (axis only applies to `keyframeEffect`). | `POINTER_AXIS` |
215216
| `INVALID_CSS_PROPERTY_NAME` | A keyframe or state-effect property name is neither camelCase nor kebab-case (both are accepted). | `CSS_PROPERTY_NAME` |
216217
| `INVALID_INSET` | `viewEnter` `params.inset` is not 1–4 CSS lengths/percentages. | `VIEW_INSET` |
218+
| `FUNCTION_OFFSET_EASING` | A sequence's `offsetEasing` is a function, so `generate()` omits that sequence from the generated CSS. | `OFFSET_EASING` |
217219

218220
## Usage recipes
219221

packages/interact-validate/src/errors.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ const RULE_CODE_MAP: Record<string, string> = {
3333
POINTER_AXIS_IGNORED: 'POINTER_AXIS',
3434
INVALID_CSS_PROPERTY_NAME: 'CSS_PROPERTY_NAME',
3535
INVALID_INSET: 'VIEW_INSET',
36+
FUNCTION_OFFSET_EASING: 'OFFSET_EASING',
3637
};
3738

3839
export function finalize(

packages/interact-validate/src/semantic/collectSemanticWarnings.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
// `collectSemanticWarnings` (consumed by the schema `transform`).
55

66
import type { Path, SemanticIssue, AnyConfig, Visitors } from '../types';
7+
import { checkFunctionOffsetEasing } from './cssGeneration';
78
import { checkCSSPropertyNames, checkInvalidInset } from './cssSyntax';
89
import { checkSameElementRetrigger, checkHitAreaShift } from './fouc';
910
import {
@@ -86,6 +87,7 @@ export function collectSemanticWarnings(config: AnyConfig): SemanticIssue[] {
8687
? { ...((config.sequences ?? {})[sequenceId] ?? {}), ...sequence }
8788
: sequence;
8889
warnings.push(...checkSameElementRetrigger(path, resolvedSequence, owner));
90+
warnings.push(...checkFunctionOffsetEasing(path, sequence));
8991
},
9092
});
9193

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { Path, SemanticIssue, AnySequence } from '../types';
2+
3+
// `generate()` compiles a sequence's stagger into a `calc()` delay driven by
4+
// `--motion-<sequenceId>-index` custom properties, so `offsetEasing` has to be a string it can
5+
// turn into CSS math. A `(p: number) => number` function has no CSS equivalent, and the whole
6+
// sequence is dropped from the generated CSS — it still animates once Interact initializes, but
7+
// nothing is pre-rendered, so a `viewEnter` sequence loses its FOUC-prevention rules.
8+
export function checkFunctionOffsetEasing(path: Path, sequence: AnySequence): SemanticIssue[] {
9+
if (typeof sequence.offsetEasing !== 'function') return [];
10+
11+
return [
12+
{
13+
code: 'custom',
14+
params: { domainCode: 'FUNCTION_OFFSET_EASING' },
15+
path: [...path, 'offsetEasing'],
16+
message:
17+
'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(...)`.',
18+
},
19+
];
20+
}

packages/interact-validate/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export type AnyEffect = {
5353
export type AnySequence = {
5454
triggerType?: string;
5555
sequenceId?: string;
56+
offsetEasing?: string | ((...args: unknown[]) => unknown);
5657
effects?: AnyEffect[];
5758
conditions?: string[];
5859
};
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { validateInteractConfig } from '../../src';
3+
4+
const EFFECTS = [{ namedEffect: { type: 'FadeIn' }, duration: 400 }];
5+
6+
describe('offsetEasing', () => {
7+
describe('FUNCTION_OFFSET_EASING (warning)', () => {
8+
it('warns on an inline sequence with a function offsetEasing', () => {
9+
const result = validateInteractConfig({
10+
interactions: [
11+
{
12+
key: 'el',
13+
trigger: 'viewEnter',
14+
sequences: [{ offset: 100, offsetEasing: (p: number) => p ** 2, effects: EFFECTS }],
15+
},
16+
],
17+
});
18+
const err = result.errors.find((e) => e.code === 'FUNCTION_OFFSET_EASING');
19+
20+
expect(err).toBeDefined();
21+
expect(err?.severity).toBe('warning');
22+
expect(err?.path).toEqual(['interactions', 0, 'sequences', 0, 'offsetEasing']);
23+
expect(result.valid).toBe(true);
24+
});
25+
26+
it('warns once, at the definition, for a referenced registry sequence', () => {
27+
const result = validateInteractConfig({
28+
sequences: {
29+
stagger: { offset: 100, offsetEasing: (p: number) => p ** 2, effects: EFFECTS },
30+
},
31+
interactions: [
32+
{ key: 'el', trigger: 'viewEnter', sequences: [{ sequenceId: 'stagger' }] },
33+
{ key: 'el2', trigger: 'viewEnter', sequences: [{ sequenceId: 'stagger' }] },
34+
],
35+
});
36+
const errs = result.errors.filter((e) => e.code === 'FUNCTION_OFFSET_EASING');
37+
38+
expect(errs).toHaveLength(1);
39+
expect(errs[0].path).toEqual(['sequences', 'stagger', 'offsetEasing']);
40+
});
41+
42+
it('warns when a reference overrides a string easing with a function', () => {
43+
const result = validateInteractConfig({
44+
sequences: { stagger: { offset: 100, offsetEasing: 'quadIn', effects: EFFECTS } },
45+
interactions: [
46+
{
47+
key: 'el',
48+
trigger: 'viewEnter',
49+
sequences: [{ sequenceId: 'stagger', offsetEasing: (p: number) => p ** 2 }],
50+
},
51+
],
52+
});
53+
const errs = result.errors.filter((e) => e.code === 'FUNCTION_OFFSET_EASING');
54+
55+
expect(errs).toHaveLength(1);
56+
expect(errs[0].path).toEqual(['interactions', 0, 'sequences', 0, 'offsetEasing']);
57+
});
58+
59+
it.each(['linear', 'quadIn', 'cubic-bezier(0.25, 0.1, 0.25, 1)', 'linear(0, 0.5 50%, 1)'])(
60+
'does not warn for the string easing %s',
61+
(offsetEasing) => {
62+
const result = validateInteractConfig({
63+
interactions: [
64+
{
65+
key: 'el',
66+
trigger: 'viewEnter',
67+
sequences: [{ offset: 100, offsetEasing, effects: EFFECTS }],
68+
},
69+
],
70+
});
71+
72+
expect(result.errors.filter((e) => e.code === 'FUNCTION_OFFSET_EASING')).toHaveLength(0);
73+
},
74+
);
75+
76+
it('does not warn when offsetEasing is omitted', () => {
77+
const result = validateInteractConfig({
78+
interactions: [
79+
{
80+
key: 'el',
81+
trigger: 'viewEnter',
82+
sequences: [{ offset: 100, effects: EFFECTS }],
83+
},
84+
],
85+
});
86+
87+
expect(result.errors.filter((e) => e.code === 'FUNCTION_OFFSET_EASING')).toHaveLength(0);
88+
});
89+
});
90+
91+
describe('OFFSET_EASING rule category', () => {
92+
const config = {
93+
interactions: [
94+
{
95+
key: 'el',
96+
trigger: 'viewEnter',
97+
sequences: [{ offset: 100, offsetEasing: (p: number) => p ** 2, effects: EFFECTS }],
98+
},
99+
],
100+
};
101+
102+
it('can be silenced via severityOverrides', () => {
103+
const result = validateInteractConfig(config, {
104+
severityOverrides: { OFFSET_EASING: 'off' },
105+
});
106+
107+
expect(result.errors.filter((e) => e.code === 'FUNCTION_OFFSET_EASING')).toHaveLength(0);
108+
});
109+
110+
it('can be demoted to info', () => {
111+
const result = validateInteractConfig(config, {
112+
severityOverrides: { OFFSET_EASING: 'info' },
113+
});
114+
115+
expect(result.errors.find((e) => e.code === 'FUNCTION_OFFSET_EASING')?.severity).toBe('info');
116+
});
117+
118+
it('is promoted to an error by strict', () => {
119+
const result = validateInteractConfig(config, { strict: true });
120+
121+
expect(result.errors.find((e) => e.code === 'FUNCTION_OFFSET_EASING')?.severity).toBe(
122+
'error',
123+
);
124+
expect(result.valid).toBe(false);
125+
});
126+
});
127+
});

packages/interact/docs/api/types.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -772,8 +772,8 @@ type SequenceOptionsConfig = {
772772

773773
- `delay` - Base delay (ms) applied to all effects in the sequence. Default: `0`.
774774
- `offset` - Stagger interval (ms) between consecutive effects. Default: `0`.
775-
- `offsetEasing` - Easing function or named string for offset distribution (`'linear'`, `'quadIn'`, `'sineOut'`, etc.). Default: `linear`.
776-
- `sequenceId` - Optional ID for referencing a reusable sequence from `InteractConfig.sequences`.
775+
- `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).
776+
- `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.
777777
- `conditions` - Optional array of condition IDs. When set, the sequence is only active when all conditions match.
778778
- `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'`.
779779

packages/interact/docs/examples/list-patterns.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -904,6 +904,10 @@ Different `offsetEasing` values produce distinct stagger patterns:
904904
{ offset: 80, offsetEasing: 'sineOut' }
905905
```
906906

907+
Any string easing works — named keys, `cubic-bezier(...)`, or `linear(...)`. Keep it a string rather than a
908+
function if the list should also be staggered by generated CSS; see
909+
[Stagger in Generated CSS](../guides/sequences.md#stagger-in-generated-css).
910+
907911
### 20. Reusable Sequences with `sequenceId`
908912

909913
Define a sequence once, reference it from multiple interactions:

packages/interact/docs/guides/sequences.md

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,35 @@ For example, with 5 effects and `offset: 200`:
1818
| `quadIn` | 0, 50, 200, 450, 800 | Slow start, then rapid |
1919
| `sineOut` | 0, 306, 565, 739, 800 | Fast start, then gradual |
2020

21+
## Stagger in Generated CSS
22+
23+
`generate()` emits **one** animation rule per sequence effect, not one per list item — the item count
24+
isn't known when the CSS is produced. The per-item delay is therefore expressed as a `calc()` over two
25+
custom properties naming the element's position in the sequence:
26+
27+
```css
28+
animation: card-entrance 600ms
29+
calc((0 + <offsetEasing(index / last) > * 80 * var(--motion-card-stagger-last, 1)) * 1ms) …;
30+
```
31+
32+
At runtime the `Sequence` writes `--motion-<sequenceId>-index` and `--motion-<sequenceId>-last` onto each
33+
target element, and the shared rule resolves to a different delay per item. Before that — during SSR and
34+
until Interact initializes — the `var()` fallbacks resolve `index` to `0`, so every element sits at the
35+
base delay and the CSS stays valid and FOUC-free.
36+
37+
This is why sequences need a stable `sequenceId`: it names the custom properties, and the CSS half and the
38+
runtime half must agree on it. When you don't provide one, Interact derives it from the sequence's position
39+
in the config (`seq-<interactionIndex>-<sequenceIndex>`) so both halves compute the same value from the same
40+
config.
41+
42+
> **`offsetEasing` must be a string for CSS generation.** A `(p: number) => number` function has no CSS
43+
> equivalent, so `generate()` skips the entire sequence — the animations still run once Interact
44+
> initializes, but nothing is rendered ahead of time and entrance animations may flash. Use a named easing,
45+
> `cubic-bezier(...)`, or `linear(...)` for anything that needs generated CSS.
46+
>
47+
> [`@wix/interact-validate`](https://github.com/wix/interact/blob/master/packages/interact-validate/README.md)
48+
> reports this statically as the `FUNCTION_OFFSET_EASING` warning.
49+
2150
## Config Structure
2251

2352
Sequences can be defined at two levels:
@@ -124,11 +153,15 @@ type SequenceOptionsConfig = {
124153
delay?: number; // Base delay (ms). Default: 0
125154
offset?: number; // Stagger interval (ms). Default: 0
126155
offsetEasing?: string | ((p: number) => number); // Easing for offset distribution
127-
sequenceId?: string; // ID for reusable sequence reference
156+
sequenceId?: string; // ID for reusable sequence reference, and for the CSS stagger custom properties
128157
conditions?: string[]; // Media query condition IDs
129158
};
130159
```
131160

161+
A function `offsetEasing` works at runtime but excludes the sequence from
162+
[generated CSS](#stagger-in-generated-css). Auto-generated `sequenceId`s are derived from the config
163+
position, so `generate()` and the runtime agree on them.
164+
132165
### `SequenceConfig`
133166

134167
Inline sequence definition (extends `SequenceOptionsConfig`):

packages/interact/rules/full-lean.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -528,8 +528,10 @@ Coordinate multiple effects with staggered timing. Prefer sequences over manual
528528
effects: (Effect | EffectRef)[]; // REQUIRED
529529
delay?: number; // ms before sequence starts
530530
offset?: number; // ms between each child's animation start
531-
offsetEasing?: string; // easing curve for staggering offsets
532-
sequenceId?: string; // for caching/referencing
531+
offsetEasing?: string; // easing curve for staggering offsets - keep it a string, a
532+
// function excludes the sequence from generated CSS
533+
sequenceId?: string; // for caching/referencing; also names the CSS stagger custom
534+
// properties. Defaults to `seq-<interactionIdx>-<sequenceIdx>`
533535
conditions?: string[]; // ids referencing the top-level conditions map
534536
}
535537
```

0 commit comments

Comments
 (0)