diff --git a/apps/playground/src/components/inspector/pg-condition-editor.ts b/apps/playground/src/components/inspector/pg-condition-editor.ts index 49adae0a..e2d72ac4 100644 --- a/apps/playground/src/components/inspector/pg-condition-editor.ts +++ b/apps/playground/src/components/inspector/pg-condition-editor.ts @@ -12,13 +12,11 @@ import { generateId } from '../../utils/id'; const CONDITION_TYPES: { value: Condition['type']; label: string }[] = [ { value: 'media', label: 'Media Query' }, - { value: 'container', label: 'Container' }, { value: 'selector', label: 'Selector' }, ]; const PREDICATE_PLACEHOLDERS: Record = { media: '(min-width: 768px)', - container: '(min-width: 400px)', selector: ':nth-of-type(odd)', }; diff --git a/element-resolution-plan.md b/element-resolution-plan.md new file mode 100644 index 00000000..7c914da9 --- /dev/null +++ b/element-resolution-plan.md @@ -0,0 +1,405 @@ +# Element resolution in `@wix/interact` — research, options, and plan + +**Status:** research + proposal. No code changed. +**Date:** 2026-07-30 +**Subject:** the `ElementIdentifier` refiner fields — `selector`, `listContainer`, `listItemSelector` — on both `InteractionTrigger` (source side) and `EffectBase` (target side). +**Triggered by:** `interact-documentation-site-audit.md` items B11, §3.2, §3.3, §3.4, A5, A10, plus §11 per-page items on the three list pages. + +--- + +## 0. Summary + +### The three fields have distinct, legitimate jobs + +The conceptual model is sound and non-redundant — **identity → collection → item → part**: + +- **`key`** — identity / registration handle (controller, `[data-interact-key]` CSS anchor, cross-element targeting). +- **`listContainer`** — "this is a repeating collection": per-item trigger binding, `MutationObserver` tracking, stagger index, per-item state. +- **`listItemSelector`** — "and _this_ is what counts as one item" (containers legitimately hold headers, sentinels, template nodes, ad slots). +- **`selector`** — "and attach to this descendant, not the scope element itself". + +The clearest statement of intent anywhere in the repo is in the skill (`skills/interactor/references/config-schema.md:271-273`): _"one trigger fanning across many targets is the `selector` case; `listContainer` is for when each item needs its own trigger."_ + +**The problem is not the model.** It is that only **two of the three refiners** are implemented in the JS path, `selector` silently changes meaning depending on whether `listContainer` is present, and the CSS pipeline implements a _different_ version of the model than the JS pipeline. + +### Headline findings (beyond what the audit already had) + +| Finding | Why it matters | +| :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------- | +| **Per-item state effects never fire.** `createTransitionHandler` finds the item via `closest('.list > .item:has(:scope)')`, and `:has(:scope)` never matches the element itself (verified in **Chrome and jsdom**). Since the target _is_ the item in the canonical list config, the lookup returns `null` and `toggleEffect` silently early-returns. It only works if the effect also carries a `selector`. | A documented feature is a silent no-op. (D1) | +| **…and even then the CSS cannot match.** `generate()` anchors the state on the keyed host (`[data-interact-key=k]:is(…,[data-interact-effect~=fx]) .list > .item`) while the runtime writes the attribute on the **item**. | Two halves of one feature assume opposite placements. (D2) | +| **Cross-item mispairing.** Source↔target pairing is zip-by-array-index: `btn2` in card 2 animated `box1b` in card 1. | Wrong element animates. (D6) | +| **`listItemSelector` narrows _all_ generated CSS**, not just `transition`/state CSS. | The CSS-bound set is narrower than the JS-bound set; the doc site's most accurate description still understates it. (D4) | +| **Resolution leaks across nested keyed roots.** `key: 'outer', selector: '.card'` also bound a `.card` inside `
`. | One component silently animates another's DOM. (D7) | +| **The validator ships a false warning.** `REDUNDANT_SELECTOR_WITH_LIST_ITEM` tells users to delete a field that is doing work in both pipelines. | Actively harmful guidance. (D12) | +| **Selector conditions are the existing dynamic item-filter** (working demo in `apps/demo/src/web/components/SelectorConditionDemo.tsx`) — but `viewProgress` and `pointerMove` ignore them. | The right mechanism for "which items _right now_" is not universal. (D13) | + +13 defects total ([§4](#4-defects-and-divergences)), each with file references and execution evidence, plus [§4.1](#41-documentation-says-four-mutually-exclusive-things): the documentation currently states **four mutually exclusive things** about these fields. + +### Options, scored + +Five options rated on 9 criteria (ease of use, teachability, LLM-generation friendliness, expressive power, back-compat, implementation cost, external migration cost, defects fixed, risk of new ambiguity) — full matrix in [§7](#7-scoring-and-recommendation): + +| Option | Total / 45 | +| :------------------------------------------------------------------------ | :--------: | +| **B — structured `list: { container, item }`, single-meaning `selector`** | **40** | +| A — implement the documented model, keep field names | 35 | +| D — discriminated union (`list` / `item` / `within`) | 35 | +| C — drop `listItemSelector`, filter via selector conditions | 30 | +| E — docs-only, freeze the runtime | 26 | + +### Recommendation + +**A then B, as two shipped phases — not a fork.** They are the same resolution semantics with different spellings, so Phase 1 carries all the risk and Phase 2 is a pure normalisation step in `parseConfig`. Option C's real insight is kept (structural filtering and stateful filtering are different jobs). Option E becomes **Phase 0** and ships immediately to unblock the docs launch. + +The core of the plan is a single `resolveElements()` returning `{ element, item, itemIndex }`, used by JS bind, JS mutation, CSS emission **and** teardown — plus a cross-pipeline invariant test asserting `querySelectorAll(toCSSSelector(id))` ≡ `resolveElements(id, root)`. That one test is what stops this class of drift from recurring. + +Four questions cannot be answered from this repo ([§11](#11-open-questions-for-the-team)); the gating one is **whether the Wix editor emits `listItemSelector` expecting it to filter**, since that decides whether Phase 1 changes rendered output or removes bindings. + +--- + +## Table of contents + +0. [Summary](#0-summary) +1. [Method](#1-method) +2. [What the three fields are _for_](#2-what-the-three-fields-are-for-reconstructed-intent) +3. [What the code actually does](#3-what-the-code-actually-does) +4. [Defects and divergences (D1–D13)](#4-defects-and-divergences) +5. [The ambiguities that must be decided (Q1–Q10)](#5-the-ambiguities-that-must-be-decided) +6. [Options](#6-options) +7. [Scoring and recommendation](#7-scoring-and-recommendation) +8. [The plan](#8-the-plan) +9. [Test plan](#9-test-plan) +10. [Downstream updates and audit items closed](#10-downstream-updates-and-audit-items-closed) +11. [Open questions for the team](#11-open-questions-for-the-team) + +--- + +## 1. Method + +Read: `packages/interact/src/core/{add,Interact,InteractionController,css,cssUtils,remove,resolvers,utilities}.ts`, `src/handlers/{eventTrigger,effectHandlers,viewEnter,viewProgress,pointerMove,animationEnd}.ts`, `src/utils.ts`, `src/types/config.ts`, `src/web/InteractElement.ts`, `src/react/interactRef.ts`, `src/dom/api.ts`; `packages/interact-validate/src/semantic/{ignored,fouc}.ts`; `packages/interact/docs/api/element-selection.md`, `docs/guides/lists-and-dynamic-content.md`, `rules/{full-lean,integration,validate}.md`; `skills/interactor/references/config-schema.md`; `interact-documentation-site.md` (L3590-3645, L4340-4475); `apps/demo/src/{web,react}/components/*`; `packages/interact/test/{css,mini,resolvers,web}.spec.ts`. + +Ran: a throwaway vitest probe (14 cases) against the real `add()` / `generate()` code paths in jsdom, plus a Chrome check of the one browser-dependent selector trick. Probe kept at +`/private/tmp/claude-501/-Users-ameerf-repos-interact/…/scratchpad/probe_selectors.spec.ts` (removed from the repo). Findings below marked **[P*n*]** were produced by execution; **[Chrome]** was verified in a real browser. + +Also read history: `39f4bf2` (initial implementation), `a213c53` "Interact selector all" (#96, Feb 2026) — the commit that changed `selector` from single- to multi-match and is the origin of most of the drift. + +--- + +## 2. What the three fields are _for_ (reconstructed intent) + +`ElementIdentifier` (`src/types/config.ts:53-58`) is the same shape on both sides of a config: + +```ts +type ElementIdentifier = { + key: string; // which registered root + listContainer?: string; // "there is a repeating collection here" + listItemSelector?: string; // "…and *this* is what counts as one item" + selector?: string; // "…and attach to this descendant, not the root/item itself" +}; +``` + +Reading intent off the code, the tests, the demos and the agent rules, the four fields are **not** redundant — each owns a distinct axis: + +| Field | Axis it owns | What it buys you | Best statement of intent found in the repo | +| :----------------- | :--------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `key` | **identity** | controller registration, `[data-interact-key]` CSS anchoring, cross-element targeting, caching | `rules/full-lean.md:686` | +| `selector` | **refinement** — _which part_ of a scope | attach/animate a descendant instead of the scope element itself (delegated triggers, "zoom the `img` not the card", hit-area stability) | `skills/interactor/references/config-schema.md:68-72` | +| `listContainer` | **list-ness** — _this is a collection_ | (a) one trigger binding **per item**, (b) `MutationObserver` tracking of added/removed items, (c) an **item index** for stagger, (d) per-item state for `transition` effects | `skills/interactor/references/config-schema.md:271-273`: "one trigger fanning across many targets is the `selector` case; `listContainer` is for when each item needs its **own** trigger" | +| `listItemSelector` | **item definition** — _what counts as an item_ | lets a container hold non-items (header, sentinel, template node, ad slot) without them becoming items | doc site L4462: "Narrows which direct children count as list items" | + +That is a coherent four-field model: **identity → collection → item → part**. The problems are not in the model; they are that only _two_ of the three refiners are implemented in the JS path, `selector` carries two different meanings depending on whether `listContainer` is present, and the CSS pipeline and the JS pipeline implement _different_ versions of the model. + +--- + +## 3. What the code actually does + +### 3.1 The JS bind-time resolver — `_getElementsFromData` (`src/core/add.ts:43-77`) + +Root = `controller.element` (the keyed element; for `web` that is the `` host — the `useFirstChild` hop is **not** applied in the list/selector branches, only in the fallback). + +1. `listContainer` set → `container = root.querySelector(listContainer)`; if not found: `console.warn` + return `[]`. + - **also** `selector` → `Array.from(container.querySelectorAll(selector))` — flat, **any depth**, item boundaries ignored, `listItemSelector` ignored. + - else → `Array.from(container.children)` — **every** element child, `listItemSelector` ignored. +2. else `selector` set → `root.querySelectorAll(selector)`; if empty: `console.warn` and **fall through to 3**. +3. `useFirstChild ? root.firstElementChild : root`. + +`listItemSelector` never appears in this function. Its only three consumers are: + +- `getSelector(…, { addItemFilter: true })` → CSS emission (`src/core/Interact.ts:340`); +- the `closest()` item lookup for state effects (`src/handlers/effectHandlers.ts:110-113`); +- `getElementHash()` → target identity (`src/core/utilities.ts:30-33`). + +### 3.2 The JS mutation-time resolver — `_queryItemElement` (`src/core/add.ts:79-85`) + +For children reported by the `MutationObserver` (`InteractionController._childListChangeHandler:167-191`): +`selector ? child.querySelector(selector) : child` — **one** match per item, and **no** filter on which added nodes count as items. + +### 3.3 Pairing — `_applyInteraction` (`src/core/add.ts:107-154`) + +- sources array + targets array → **zip by array index**; sources past the end of the target array are silently dropped. +- sources array + single target → every source drives that one target. +- single source + targets array → that source drives every target. + +### 3.4 CSS emission — `getSelector` (`src/core/Interact.ts:331-353`) + +Consumed by `triggerToCSS` (`css.ts:150-154`), `parseEffect` (`css.ts:316-320`), `parseSequence` (`css.ts:377-381`) — all with `addItemFilter: true` — and by the runtime state-effect path `createTransitionCSS` (`add.ts:767-771`). `parseConfig` also stores an item-filter-less variant per key for **teardown** (`Interact.ts:428-430`, `486`, `511`; consumed by `remove.ts:19-27`). + +### 3.5 The full agreement matrix **[P4, P2, P11, P13]** + +| Config | CSS emitted (`addItemFilter`) | JS at bind | JS on mutation | Teardown selector | Agree? | +| :----------------------------------- | :---------------------------- | :--------------------------------------- | :----------------------------------------- | :---------------------- | :---------------------- | +| _(none)_ | `> :first-child` (web) / `''` | `firstElementChild` / root | — | `:scope > :first-child` | ✅ | +| `selector` | `.sel` | `root.querySelectorAll('.sel')` | — | `.sel` | ✅ | +| `listContainer` | `.list > *` | `container.children` | each added child | `.list > *` | ✅ | +| `listContainer` + `listItemSelector` | `.list > .item` | **`container.children` (all)** | **every added child** | `.list > *` | ❌ CSS narrower than JS | +| `listContainer` + `selector` | `.list .sel` | `container.querySelectorAll('.sel')` | **`child.querySelector('.sel')` (1/item)** | `.list .sel` | ❌ bind ≠ mutation | +| all three | `.list > .item .sel` | **`container.querySelectorAll('.sel')`** | `child.querySelector('.sel')` | `.list .sel` | ❌ three different sets | +| `listItemSelector` alone | ignored | ignored | — | ignored | ✅ (inert) | + +--- + +## 4. Defects and divergences + +Ordered by user impact. Each is evidence-backed. + +| # | Defect | Evidence | +| :------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **D1** | **Per-item state (`transition`) effects never fire in the canonical list config.** `createTransitionHandler` resolves the item with `element.closest(\`${listContainer} > ${listItemSelector \|\| ''}:has(:scope)\`)` (`effectHandlers.ts:110-113`), and `element` is the **target** (`eventTrigger.ts:129-135`). `:has(:scope)`matches only elements that have the scoping element as a **descendant**, so when the target *is* the item the lookup returns`null`— and`toggleEffect`early-returns on`null` (`InteractionController.ts:112-114`). Result: click/hover on a list item with a `transition` effect does **nothing, silently**. | **[P0, Chrome]** `item.closest('.list > :has(:scope)') === null`, `item.closest('.list > .item:has(:scope)') === null`, `btn.closest(…) ===
  • `. **[P6, P7, P8]** no `data-interact-effect` written anywhere. **[P12]** it _does_ work when the effect adds a `selector`, i.e. the target is a descendant of the item. | +| **D2** | **Even when the item resolves, the generated CSS cannot match it.** The state rule anchors the state on the **keyed host**: `[data-interact-key="k"]:is(:state(fx), :--fx, [data-interact-effect~="fx"]) .list > .item { … }` (`cssUtils.ts:123-136`), while the runtime writes the attribute on the **item**. Two halves of one feature assume opposite placements. | **[P13]** exact generated CSS; `createTransitionCSS` (`utils.ts:122-123`) has the same shape. | +| **D3** | **`listItemSelector` does not filter JS binding.** All immediate children become sources/targets, including children that do not match it. | **[P1]** `listContainer: '.list', listItemSelector: '.active'` → all 3 children (`.active`, non-`.active`, and `.other`) each got a click listener. | +| **D4** | **`listItemSelector` _does_ narrow all generated CSS** — not only `transition`/state CSS as the docs' best statement claims, but also `view-timeline`, animation custom properties and the FOUC initial rules. So for CSS-driven animations the effective set differs from the JS-bound set. | `css.ts:150-154, 316-320, 377-381` all pass `addItemFilter: true`. **[P5]** | +| **D5** | **`listContainer` + `selector` resolves differently at bind vs. mutation.** Bind: all matches in the container, any depth, item boundaries ignored. Mutation: exactly one match per new item. | **[P2]** 3 cards → `a1, a2, b1, c1` (two images from one card, one from a nested wrapper). **[P11]** a card appended with two images contributes only `new1`. (= audit A10.) | +| **D6** | **Cross-item mispairing.** Because pairing is zip-by-array-index, a source in item _i_ can be paired with a target in item _j_. | **[P3]** sources `btn1,btn2,btn3` (one per card) zipped to targets `box1,box1b,box2` → `btn2`→`box1b` (card 1), `btn3`→`box2` (card 2). | +| **D7** | **Resolution leaks across nested keyed roots.** `selector`/`listContainer` are plain descendant queries; they match inside a nested `[data-interact-key]` subtree that belongs to a different controller. | **[P9]** `key: 'outer', selector: '.card'` bound both `#own` and the `#nested` card inside `
    `. | +| **D8** | **A missed `selector` silently falls back to the whole element.** `add.ts:64-72` warns and then returns `firstElementChild`/root, so a typo animates the entire component instead of failing. (Contrast `_resolveSourceElements:362-374`, which _does_ treat empty as "nothing" — the two paths disagree.) | `add.ts:64-76` | +| **D9** | **`listItemSelector` participates in element identity**, so two effects that resolve to the _same_ DOM elements at runtime get different CSS custom-property names, different cascade slots, and fail the FOUC same-element check. | `utilities.ts:30-33`; `shouldUseInitial:19-28`; `css.spec.ts:325-348` encodes the "differing `listItemSelector` ⇒ not the same element" behaviour. **[P5]** two rules, hashes `9qtig32rhc` vs `zlegxcnuqy`. | +| **D10** | **Stagger indices break for `listContainer` + `selector`.** `_resolveListItemIndices` (`add.ts:401-420`) does `container.children.indexOf(el)`, but `el` is a _descendant_ of a child in this mode → `-1` → every dynamically added element gets pushed to the end index. | `add.ts:414-419` | +| **D11** | **The `useFirstChild` hop is applied inconsistently.** `_getElementsFromData` queries from the host; `_resolveListItemIndices` queries from `firstElementChild`; `getSelector` skips the hop whenever a refiner is present. | `add.ts:48, 74-76, 406-410`; `Interact.ts:339-352` | +| **D12** | **The validator ships a false warning.** `REDUNDANT_SELECTOR_WITH_LIST_ITEM` says "`selector` is ignored when both `listContainer` and `listItemSelector` are present" — untrue in both pipelines (`selector` wins in JS; in CSS they compose to `.list > .item .sel`). It tells users to delete a field that is doing work. | `interact-validate/src/semantic/ignored.ts:23-43`; `rules/validate.md:232`; **[P4]** | +| **D13** | **Item filtering cannot be expressed dynamically today, and the one mechanism that _can_ is not universal.** Selector conditions (`type: 'selector'`) are re-checked at event time (`effectHandlers.ts:38, 108`, `viewEnter.ts:199`, `animationEnd.ts:40`) — the correct tool for "only `.active` items". But `viewProgress` and `pointerMove` ignore `selectorCondition` entirely. | grep: no `selectorCondition` in `handlers/viewProgress.ts`, `handlers/pointerMove.ts`; `apps/demo/src/web/components/SelectorConditionDemo.tsx` is the working pattern | + +### 4.1 Documentation says four mutually exclusive things + +| Story | Where | True? | +| :--------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------- | :----------------------------------------------------- | +| `listItemSelector` filters which children participate | `rules/full-lean.md:691`, `rules/integration.md:197,203`, `skills/…/config-schema.md:54,324`, doc site L3601-3618, L4358-4369 | ❌ (D3) | +| `listItemSelector` narrows only `transition`/state CSS | doc site L4462 | ⚠️ narrows _all_ CSS (D4) | +| `selector` is ignored when `listContainer` + `listItemSelector` are both set | `interact-validate` `REDUNDANT_SELECTOR_WITH_LIST_ITEM` | ❌ (D12) | +| `listContainer` + `selector` = `querySelector` inside each direct child | doc site L3621, L3635, `rules/full-lean.md:693`, `skills/…:326` | ⚠️ true only on the mutation path (D5) | +| `selector` alone selects the **first** matching descendant | doc site L3637 | ❌ since `a213c53` (Feb 2026) it is `querySelectorAll` | + +--- + +## 5. The ambiguities that must be decided + +Any option below has to answer all ten. These are the actual design questions hiding behind the three fields. + +| # | Question | Recommended answer | +| :------ | :------------------------------------------------------------------------------------------------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Q1** | Does `selector` match at any depth, or only direct children? | Any depth (descendant). One rule, everywhere. | +| **Q2** | Does `selector` yield one element or all matches? | **All** matches. Kill the "first match" story for good. | +| **Q3** | Does resolution stop at a nested keyed root? | **Yes** — exclude any candidate whose nearest `[data-interact-key]` ancestor is not this root. (D7) | +| **Q4** | Is the `web` `firstElementChild` hop applied before refiner queries? | **No** (a descendant query crosses the wrapper anyway) — but state it once and make all three code paths agree. (D11) | +| **Q5** | When `selector` resolves _inside_ list items, does each item contribute one element or all its matches? | **All** matches, each carrying its **owning item**. (D5) | +| **Q6** | How are a source list and a target list paired? | **By owning item** when both sides describe the same list on the same key; otherwise **fan out** (every source drives every target). Never zip by raw index. (D6) | +| **Q7** | Is item filtering static (bind-time) or dynamic (event-time)? | `listItemSelector` is **static structural** ("what is an item"). Dynamic subsets (`.active`, `:nth-child(even)`) are **selector conditions** — and those must start working for `viewProgress`/`pointerMove`. (D13) | +| **Q8** | For a list state effect, where does the state live and what CSS matches it? | On the **owning item**; CSS becomes `[key] .list > .item[data-interact-effect~=fx] `. Without a list: on the keyed host, as today. (D1, D2) | +| **Q9** | What happens when a refiner matches nothing? | Warn once and **bind nothing**. No fallback to the root. (D8) | +| **Q10** | Is `listItemSelector` a compound selector or a full selector? | **Compound, relative to the container** (`.item`, `li`, `[data-item]`); reject anything containing a combinator, since it is interpolated as `${listContainer} > ${listItemSelector}`. | + +--- + +## 6. Options + +All options assume the Q1–Q10 answers above and a **single shared resolver** used by JS bind, JS mutation, CSS emission, and teardown. They differ in the _config surface_ users write. + +### Option A — Implement the documented model, keep the field names + +Three refiners stay. One resolver: + +``` +root = keyed element (web: host; queries are descendant queries so the wrapper is transparent) +scope = listContainer ? root.querySelector(listContainer) : root +items = listContainer + ? [...scope.children].filter(c => !listItemSelector || c.matches(listItemSelector)) + : [root] +result = selector ? items.flatMap(i => [...i.querySelectorAll(selector)].map(el => ({el, item: i}))) + : items.map(i => ({el: i, item: listContainer ? i : undefined})) +``` + +`listItemSelector` starts filtering; the mutation path filters added nodes the same way; each resolved element remembers its owning item (fixes D5, D6, D10, and makes Q8 implementable). + +- **Pros:** no config-format change; every existing _correct_ config keeps working; the docs become true roughly as already written; the validator's false warning simply disappears; the smallest possible diff to a well-defined state. +- **Cons:** `selector` keeps two meanings ("within the root" / "within each item") — the single biggest reported source of confusion stays; `listItemSelector`'s absence still silently changes item semantics; three fields to teach. +- **Behaviour change for existing configs:** `listItemSelector` becomes load-bearing (configs that set it as decoration will lose bindings — but they were already losing CSS, D4); `listContainer + selector` gains matches on the mutation path and loses container-wide flattening across non-item children. + +### Option B — Structured list, single-meaning `selector` _(recommended surface)_ + +```ts +type ElementIdentifier = { + key: string; + list?: { container: string; item?: string }; + selector?: string; // always: descendants of the scope (the root, or each item) +}; +``` + +Same resolution semantics as A. `listContainer` / `listItemSelector` become **deprecated aliases** normalised at parse time (`parseConfig` already normalises keys, ids and sequences — one place to add it). + +- **Pros:** the grammar _is_ the mental model — scope, then refinement; `item` is structurally impossible without `container`, so `LIST_ITEM_SELECTOR_WITHOUT_CONTAINER` stops existing; `selector` has exactly one meaning; the docs collapse to a three-row table; much easier for LLM config generation (the skill's biggest failure mode is picking between the three flat fields). +- **Cons:** a config-shape change to migrate (validator schema, skill, docs, and any Wix editor emitter); two spellings alive during the deprecation window; nested objects are slightly more verbose to hand-write. + +### Option C — Drop `listItemSelector`; filter with selector conditions + +Two refiners: `listContainer` (items = **all** children) and `selector`. Item filtering moves to the existing `conditions: [{ type: 'selector', predicate: '.active' }]` mechanism. + +- **Pros:** one less field and one less concept; filtering becomes uniform across _all_ resolution modes, not just lists; conditions are re-evaluated at event time, so `.active` filtering actually tracks state changes — something a bind-time `listItemSelector` can never do; already demoed and working (`SelectorConditionDemo.tsx`). +- **Cons:** requires fixing `selectorCondition` support in `viewProgress`/`pointerMove` first (D13); for _time_ effects the animation objects are still **created** for non-items and only gated at fire time (waste, and wrong for `viewEnter` bookkeeping); "my container has a header" — a purely structural concern — now needs a named condition, which reads as over-machinery; loses the ability to _not observe_ non-item children. + +### Option D — Explicit modes (discriminated union) + +```ts +type ElementIdentifier = + | { key: string } // the keyed element + | { key: string; selector: string } // descendants of it + | { key: string; list: string; item?: string; within?: string }; // items, or a part of each +``` + +- **Pros:** maximum clarity — `selector` and `within` never overlap, TypeScript discriminates the modes, the validator becomes near-trivial, and the reference page is literally three rows. +- **Cons:** largest migration; a fourth field name (`within`) to learn and to teach in every example; unions are awkward for the machine-generated configs that build identifiers field-by-field. + +### Option E — Documentation-only: describe today's behaviour exactly + +Freeze the runtime; rewrite docs, rules, skill and validator messages to match §3.5 verbatim, including the bind-vs-mutation split and "`listItemSelector` affects CSS only". + +- **Pros:** zero risk, ships this week, unblocks the docs launch. +- **Cons:** documents D1/D2 (a feature that silently does nothing), D6 (cross-item mispairing) and D5 (two different rules for the same config) as _intended behaviour_; the resulting reference page is unteachable; guarantees the same audit next cycle. + +--- + +## 7. Scoring and recommendation + +1 = poor, 5 = excellent. + +| Criterion | A (keep names) | B (`list` object) | C (drop item filter) | D (modes union) | E (docs only) | +| :---------------------------------------------- | :------------: | :---------------: | :------------------: | :-------------: | :-----------: | +| Ease of use for a package user | 3 | **5** | 4 | **5** | 1 | +| Ease of teaching / doc simplicity | 3 | **5** | 4 | **5** | 1 | +| LLM / editor config generation | 3 | **5** | 4 | 3 | 2 | +| Expressive power kept | **5** | **5** | 3 | **5** | **5** | +| Backwards compatibility | **5** | 4 (aliases) | 2 | 3 (aliases) | **5** | +| Implementation cost (5 = cheapest) | 4 | 3 | 3 | 2 | **5** | +| Migration cost outside this repo (5 = cheapest) | **5** | 3 | 2 | 2 | **5** | +| Fixes D1–D13 | 4 | **5** | 4 | **5** | 1 | +| Risk of a _new_ ambiguity | 3 | **5** | 4 | **5** | 1 | +| **Total (of 45)** | **35** | **40** | **30** | **35** | **26** | + +**Recommendation: A now, B next — as two shipped phases, not a fork.** + +The reason they compose is that A and B are the _same resolution semantics_ with different spellings. Phase 1 (A) makes one resolver the single source of truth for JS, CSS and teardown — that is where all thirteen defects actually live. Phase 2 (B) is then a pure surface change: a normalisation step in `parseConfig` plus deprecation warnings, with no runtime risk, because by then only one code path resolves elements. + +Option C's dynamic-filtering insight is **kept** and folded in: `listItemSelector` (→ `list.item`) is documented as _structural_ ("what is an item"), selector conditions as _stateful_ ("which items right now"), and fixing `selectorCondition` for `viewProgress`/`pointerMove` is a Phase 1 work item. Option D is rejected only on migration cost; if the config were greenfield it would be the pick. + +Option E is not rejected — it is **Phase 0**, and it should ship immediately, because the docs launch is blocked on B11 and the truth is currently unwritten anywhere. + +--- + +## 8. The plan + +### Phase 0 — Tell the truth, unblock the docs (no runtime change) + +| # | Work item | Files | +| :-- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 0.1 | Write one canonical **Element resolution** reference containing the §3.5 matrix, and make every other surface link to it instead of restating it. | `packages/interact/docs/api/element-selection.md` (rewrite; it currently never mentions `listItemSelector` at all) | +| 0.2 | Fix the four false statements in the doc site: recap step 2 & 3 (L3633-3638), the `.active` example (L3601-3618), the `listContainer + selector` claim (L3621), the `listItemSelector` claim (L4358). Add "known limitation" notes for D1 and D6 until Phase 1 lands. | `interact-documentation-site.md` | +| 0.3 | Same corrections in the agent rules and the skill. | `rules/full-lean.md:201,207,220,355,691-704`, `rules/integration.md:182,195-216`, `skills/interactor/references/config-schema.md:52-54,113-117,321-333` | +| 0.4 | **Delete** `REDUNDANT_SELECTOR_WITH_LIST_ITEM` (D12) — it is actively harmful. Keep `LIST_ITEM_SELECTOR_WITHOUT_CONTAINER`. | `interact-validate/src/semantic/ignored.ts:22-43`, `src/errors.ts`, `test/rules/elementSelection.spec.ts`, `rules/validate.md:232`, `interact-validate/README.md` | +| 0.5 | Add a `LIST_ITEM_SELECTOR_CSS_ONLY` info/warning for the interim: "`listItemSelector` narrows generated CSS but not JS binding until vX". Remove it in Phase 1. | `interact-validate/src/semantic/ignored.ts` | + +### Phase 1 — One resolver (Option A semantics) + +**1.1 New module `src/core/resolveElements.ts`** — the single source of truth. Shape: + +```ts +type ResolvedElement = { element: HTMLElement; item?: HTMLElement; itemIndex?: number }; + +resolveElements(id: ElementIdentifier, root: HTMLElement, opts: { useFirstChild: boolean }): ResolvedElement[] +resolveItems(id, root, opts): HTMLElement[] // items only, for observers +resolveWithinItems(id, items: HTMLElement[]): ResolvedElement[] // mutation path, same rules +toCSSSelector(id, opts): { itemSelector?: string; childSelector: string } // replaces getSelector +``` + +Rules: Q1–Q5, Q9, Q10. `toCSSSelector` returns the item and inner parts **separately** so the state-effect generator can anchor `[data-interact-effect~=…]` on the item (Q8), and so teardown, CSS and JS derive from one function. + +**1.2 Rewire the four call sites** + +- `add.ts`: `_getElementsFromData` and `_queryItemElement` → `resolveElements` / `resolveWithinItems`; `_getInteractionElements`, `_resolveSourceElements`, `_buildAnimationGroupArgsFromSequence` carry `ResolvedElement[]`. +- `Interact.ts`: `getSelector` → `toCSSSelector`; the per-key `selectors` set (teardown) must use the **same** item filter as binding (today it does not, §3.4). +- `css.ts` / `cssUtils.ts`: `CSSRuleToString` gains item-anchored state selectors (Q8/D2). +- `InteractionController.watchChildList` / `_childListChangeHandler`: filter added/removed nodes through `list.item` before calling `addListItems` / `removeListItems` (D3). + +**1.3 Pairing by item (D6)** — `_applyInteraction` pairs source and target by `item` identity when both sides resolved from the same key + container; otherwise fans out. Delete the zip-by-index branch. `_resolveListItemIndices` becomes a lookup of `ResolvedElement.itemIndex` (D10). + +**1.4 Fix per-item state effects (D1, D2)** — drop the `:has(:scope)` trick entirely; the handler receives the owning `item` from `ResolvedElement` (already resolved, no DOM query, no browser-dependent selector). Emit the matching CSS: `[key] > [data-interact-effect~=fx] `. Verify both the runtime `createTransitionCSS` path and the `generate()` path produce the same shape (they must; consider consolidating per the existing TODO at `utils.ts:100-101`). + +**1.5 Scope to the keyed root (D7)** — post-filter candidates whose nearest `[data-interact-key]` ancestor is not this root; mirror in CSS with a `:not()` guard or document the CSS-side limitation explicitly. + +**1.6 No silent fallback (D8, Q9)** — a refiner that matches nothing warns once with the config path and binds nothing. + +**1.7 Selector conditions for all triggers (D13)** — thread `selectorCondition` into `viewProgress` and `pointerMove` so the "which items _right now_" mechanism is universal. + +**1.8 Identity (D9)** — keep `listItemSelector` in `getElementHash` (it now genuinely changes the resolved set, so distinct hashes become correct rather than accidental). Add a test pinning the FOUC same-element rule to the _resolved_ identity. + +### Phase 2 — The `list` surface (Option B) + +- 2.1 Add `list?: { container: string; item?: string }` to `ElementIdentifier`; normalise `listContainer`/`listItemSelector` → `list` in `parseConfig` (one place) and in `resolveEffectForCSS`. +- 2.2 Deprecate the flat fields: keep them working, mark `@deprecated` in types, add a validator warning with the exact replacement, ship a codemod script under `scripts/`. +- 2.3 Rewrite docs / rules / skill on the new surface, flat fields shown once in a migration note. +- 2.4 Remove the flat fields in the next major. + +--- + +## 9. Test plan + +The probe cases become the regression suite (they are all currently either failing-by-design or asserting nothing): + +| Case | Assertion after Phase 1 | +| :------------------------------------------------------------------- | :----------------------------------------------------------------- | +| `listContainer` + `listItemSelector`, mixed children | only matching children get listeners **and** CSS **and** teardown | +| dynamically appended non-item child | ignored by the observer | +| `listContainer` + `selector`, 2 matches in one item | both bound at bind time **and** on mutation (identical sets) | +| source in item _i_, target in item _j_ | never paired; each source drives its own item's target | +| unequal source/target counts | fan-out, nothing silently dropped | +| list + `transition` effect, target = item | `data-interact-effect` lands on the item; generated CSS matches it | +| list + `transition` effect, target = descendant of item | same, with the inner selector appended | +| nested `[data-interact-key]` subtree | outer key does not bind inner elements | +| `selector` matching nothing | warns; **no** fallback to root | +| stagger indices with `listContainer` + `selector`, items added later | index = owning item's index | +| `generate()` vs runtime `createTransitionCSS` for the same config | byte-identical selectors | +| `viewProgress` / `pointerMove` with a selector condition | gated like the event triggers | + +Plus a **cross-pipeline invariant test**: for a table of configs, assert that `document.querySelectorAll(toCSSSelector(id))` and `resolveElements(id, root)` return the same element set. That single test is what prevents this class of drift from coming back. + +--- + +## 10. Downstream updates and audit items closed + +| Audit item | Closed by | +| :----------------------------------------------------------- | :----------------------------------------------------------- | +| B11 (element-resolution rules contradict across three pages) | 0.1, 0.2 | +| §3.2 / A5 (`listItemSelector` does not filter) | 0.2, 0.3 + **1.2/1.4** (makes the documented behaviour true) | +| §3.3 (`selector` is `querySelectorAll`) | 0.1, 0.2 | +| §3.4 / A10 (bind vs mutation divergence) | **1.1/1.2** | +| §11 per-page items on the three list pages | 0.1–0.3 | +| _(new)_ D1, D2, D6, D7, D8, D10, D11, D12, D13 | as mapped in §8 | + +Surfaces to update in lockstep (each currently states something false): `docs/api/element-selection.md`, `docs/api/types.md`, `docs/guides/{lists-and-dynamic-content,configuration-structure,sequences}.md`, `docs/examples/{list-patterns,entrance-animations}.md`, `docs/integration/react.md`, `rules/{full-lean,integration,validate}.md`, `README.md`, `llms.txt` / `llms-full.txt`, `skills/interactor/{SKILL.md,references/config-schema.md,references/triggers.md}` and the six examples using `listContainer`, `packages/interact-validate/README.md`. + +--- + +## 11. Open questions for the team + +1. **Who else emits these configs?** If the Wix editor writes `listItemSelector` today expecting it to filter, Phase 1 changes rendered output. If it writes it as decoration, Phase 1 removes bindings. This is the one input I cannot verify from this repo and it gates 1.2. +2. **Is `listContainer + selector` meant to be "one per item" or "all per item"?** `a213c53`'s message says "If listContainer is specified then selector matches a single element", and the mutation path implements exactly that. Q5 recommends "all", for consistency with `selector` elsewhere; "one" is defensible and cheaper to migrate to. Decide before 1.1. +3. **Is a whole-list state effect a real use case?** (state on the keyed host, styling every item — what the CSS generator currently implies). If yes, Q8 needs an opt-in, e.g. state effects with `list` but no `stateScope: 'item'`. +4. **Phase 2 field names:** `list: { container, item }` vs `D`'s `list` / `item` / `within`. Naming affects the skill and every doc example, so it is worth one explicit decision rather than drifting. diff --git a/interact-documentation-site-audit.md b/interact-documentation-site-audit.md new file mode 100644 index 00000000..56040cdd --- /dev/null +++ b/interact-documentation-site-audit.md @@ -0,0 +1,233 @@ +# Audit — `interact-documentation-site.md` — remaining work + +**Audited file:** `interact-documentation-site.md` +**Audited against:** `packages/interact/src/**`, `packages/interact/rules/*.md`, `packages/interact/README.md`, `packages/interact-validate/src/**`, `packages/motion-presets/src/**`, `packages/motion/src/**`, `packages/splittext/src/plugin/**` +**Original audit:** 2026-07-26 · **Re-verified:** 2026-08-04 · **Remediation passes:** 2026-08-13 + +> **This document has been reduced to the two deliberately deferred clusters.** Everything else the +> audit raised — the blocking issues, the technical errors, the authoring residue, the structural +> and style defects, and all upstream defects outside the site doc — has been closed. + +Line numbers from the original audit no longer apply: the file grew from 4,729 to ~6,770 lines. +**Locate every item below by searching for the quoted text.** + +Canonical section anchors established during remediation — reuse these when cross-linking: + +| Topic | Anchor | +| :-------------------------------- | :-------------------------------------------------------------- | +| FOUC prevention | `/html-integration#preventing-fouc` | +| `registerEffects` setup | `/named-effects#registering-named-effects` | +| Don't guess preset options | `/named-effects#do-not-guess-preset-options` | +| `composite` | `/multi-interaction-compositions#the-composite-option` | +| Hit-area shift | `/source-and-target-resolving#hit-area-shift` | +| `overflow: hidden` caveat | `/viewprogress#overflow-hidden-breaks-the-timeline` | +| Scroll-preset `range` requirement | `/viewprogress#scroll-presets-require-range` | +| Pointer parameter reference | `/pointermove#trigger-parameters` | +| `interest` / `activate` | `/click-and-hover#accessibility-upgrades-interest-and-activate` | + +--- + +## Table of contents + +1. [Deferred cluster A — reduced motion](#1-deferred-cluster-a--reduced-motion) +2. [Deferred cluster B — selectors and element resolution](#2-deferred-cluster-b--selectors-and-element-resolution) +3. [Open decision — release gating of 2.6.0 API](#3-open-decision--release-gating-of-260-api) + +--- + +## 1. Deferred cluster A — reduced motion + +**Status: the runtime shipped 2026-08-06 (in `@wix/interact` 2.6.0, unreleased). The site doc was +deliberately left untouched, so every reduced-motion statement in it now _under_-promises.** + +Verified unchanged: reduced-motion mentions 33 → 33, `forceReducedMotion` 3 → 3, +`Interact.reducedMotion` still absent. Only admonition labels, link targets and table-column layout +were touched; no claim was altered. + +§1.1 is the **spec for the rewrite**. Nothing needs inventing — §1.2 lists where the wording exists. + +### 1.1 What the runtime now does + +- **Detection.** `Interact.forceReducedMotion` became `boolean | undefined`, default `undefined` — an + **override**, not the mechanism. A new read-only `Interact.reducedMotion` resolves + `forceReducedMotion ?? matchMedia('(prefers-reduced-motion: reduce)').matches` and is what all + seven `add.ts` call sites now read. `false` forces motion **on**; `undefined` follows the OS. + Returns `false` where there is no `window`/`matchMedia`, which is also correct for a server render. +- **Enforcement moved into CSS.** `generate()` now emits `@media (prefers-reduced-motion: reduce)` + rules alongside the base ones, so the decision holds with JS disabled, under SSR, and across a + mid-session preference change with no JS at all. `getAnimation()` still returns a matching CSS + animation before consulting the flag; that division of labour is now deliberate and pinned by a test. +- **Per effect kind** — this table is the load-bearing content for the rewrite: + + | Effect kind | Under `reduce` | What the visitor sees | + | :---------------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------- | :------------------------------------ | + | Time effect (`viewEnter`, `hover`, `click`, `interest`, `activate`, `animationEnd`) | **Collapsed** — `1ms` duration, `0ms` delay, one iteration | The end state, applied instantly | + | Ongoing time effect (`iterations: Infinity`) | **Collapsed too** — the same rule caps iterations at 1 | The end state; no perpetual motion | + | State effect (`transition` / `transitionProperties`) | **Tween dropped, state kept** — `--transition-*` is declared only under `no-preference` | The state toggles instantly | + | `viewProgress` | **Cancelled** — `view-timeline` is declared only under `no-preference`, and the handler early-returns | The element's authored **base style** | + | `pointerMove` | **Cancelled** — the handler early-returns, so the pre-generated paused CSS animation is never driven | The effect's **first keyframe** | + | `customEffect` | JS only — collapsed to `1ms` with the default `iterations: 1`, dropped when `iterations > 1` | Its end state, or nothing | + + The two scrub rows were **verified by generating the CSS**, not inferred: a `pointerMove` + `keyframeEffect` is emitted paused on the document timeline (`animation-timeline: auto`), so it + holds frame 0; a `viewProgress` effect loses its timeline entirely, so it applies nothing. Earlier + drafts asserted "base style" for both — do not repeat that. + +- **Nothing is suppressed by name.** Collapsing rather than dropping is deliberate: it preserves the + `data-interact-enter` handshake, so a collapsed entrance can never be stranded behind its own FOUC + hiding rule. +- **An author-declared `prefers-reduced-motion` condition wins.** An effect whose own conditions — or + its interaction's — mention the feature is exempt from the collapse and runs exactly as authored. + The exemption is **per effect**, so neighbouring effects on the same target need no changes. +- **Except for a scrub, which is cancelled unconditionally.** A `viewProgress` / `pointerMove` + interaction gated on `reduce` never runs in either path — the CSS gate is forced, and the handler + early-returns regardless of conditions. So a scrub's reduced-motion alternative **must** use a + time-based trigger or a plain CSS rule. `@wix/interact-validate` reports this as + `REDUCE_GATED_SCRUB` (category `REDUCED_MOTION`, severity `warning`). +- **A `viewEnter` entrance's FOUC hiding rule is now gated** on the union of its interaction's and its + effect's conditions. Previously an interaction-level condition left it unconditional, so + `conditions: ['desktop']` on an entrance stranded the element on mobile. +- **Reactivity, per path.** CSS-backed time and state effects follow a preference change immediately + (no JS); `viewProgress` / `pointerMove` interactions rebind and pick it up; `customEffect` and other + WAAPI-only effects pick it up on their next bind. Setting the **override** suppresses the change + listener, so it is read-once by design and must be assigned before `Interact.create()`. + +### 1.2 Where the correct wording already exists + +| Source | What to lift | +| :----------------------------------------------------------------- | :------------------------------------------------------------------------------------------ | +| `packages/interact/rules/full-lean.md` → `## Reduced motion` | The canonical treatment: per-kind table, alternative-authoring rules, override + reactivity | +| `packages/interact/docs/guides/conditions-and-media-queries.md` | The same, in guide prose, with the gate-only-the-alternative example | +| `packages/interact/rules/viewprogress.md` → `## Reduced Motion` | The `viewProgress` chapter's section, near-verbatim | +| `packages/interact/rules/pointermove.md` → `## Reduced Motion` | The `pointerMove` note, including the first-keyframe fallback | +| `packages/interact/docs/api/interact-class.md` → Static Properties | The rows for `forceReducedMotion` and `reducedMotion` | + +### 1.3 Required edits, by page + +| # | Page | Edit | +| :-- | :---------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| A1 | `/understanding-conditions` | **The canonical treatment.** Rebuild `## Reduced motion` in two parts. **Part 1:** what Interact does automatically — the §1.1 per-effect-kind table verbatim, plus the "collapsed, never suppressed" invariant. **Part 2:** what conditions are still for, narrowed to the only two cases that need one (a specific calmer look; a cancelled scrub whose element is unusable), with a gate-**only**-the-alternative example and the scrub rule. Add an **Overriding the detected preference** subsection for `forceReducedMotion`'s three values and the per-path reactivity table (was M8). Source: `docs/guides/conditions-and-media-queries.md`. | +| A2 | `/html-integration` | Static-API table: the `Interact.forceReducedMotion` row still reads `boolean`, default `false`. Replace with "`boolean \| undefined` — **override** the detected motion preference. Default `undefined` = follow `prefers-reduced-motion`. `true` forces reduced motion on, `false` forces motion on. Set before `create()`." Add a row for `Interact.reducedMotion`: "`boolean`, **read-only** — the resolved decision." The table is laid out `Member \| Type \| Default \| Description`. | +| A3 | `/viewprogress` | **Add** a `## Reduced motion` section — the chapter has none. Cover: cancelled rather than collapsed, and why; both enforcement paths, so the JS-disabled case is explicit; the **base-style** fallback (not the effect's start state); the narrow condition under which an alternative is needed — only when the author's own CSS pre-hides the element; the two ways out (drop the pre-hiding, usually right; or add a `viewEnter` alternative); and that a `reduce`-gated `viewProgress` never runs (`REDUCE_GATED_SCRUB`). Do **not** write "a fallback is required" — an embellishing parallax needs nothing. | +| A4 | `/pointermove` | Rewrite "`pointerMove` effects are skipped when reduced-motion mode is enabled." → **cancelled** under a preference Interact detects itself. Name the resting state: first keyframe for a `keyframeEffect`, base style for `namedEffect` / `customEffect`, which emit no CSS on `pointerMove`. Require a time-based trigger for any alternative. Currently a `> **Warning:**` just after the intro. | +| A5 | `/time-and-scrub-effects` | "The library skips pointer-driven effects entirely when reduced motion is preferred" → "cancels", plus a link to A1. Also **invert** the accessibility paragraph: a transform-heavy `keyframeEffect` needs **no** condition, because it collapses to its final keyframe; a gated second effect is only for when that instant landing is too abrupt. | +| A6 | `/named-effects` | The `## Accessibility` section gates _both_ sides, which is now redundant. Lead with what Interact does automatically (a high-motion preset is safe unconditionally — it collapses to its end state), keep the preset-swap list, gate **only** the alternative in the example, and call out `*Scroll` presets as the one family needing author attention. | +| A7 | `/what-are-effects` | Same treatment as A6 for its reduced-motion block; cut to one paragraph plus a link to A1. Delete any "you can also force this globally: `= matchMedia(…).matches`" line — it is the default now, and it is the line real integrations copy-paste. | +| A8 | `/click-and-hover`, `/transition-effects` | Both carry "Interact only handles the visual toggle — keeping semantic state such as `aria-expanded` in sync, and respecting `prefers-reduced-motion`, is still your application's responsibility." The `aria-expanded` half stands; **invert** the reduced-motion half — under `reduce` the state still applies and only the tween is dropped. | +| A9 | `/click-and-hover` | "If you pre-generate the CSS, apply the reduced-motion guidance above" → pre-generated CSS now carries the reduced-motion rules itself; that is the point of the change. | +| A10 | `/responsive-animation-design` | "Always provide a motion-safe alternative when the primary interaction depends on animation" → narrow to the case that actually needs one: a scroll- or pointer-driven effect whose element is unusable without it, with a time-based alternative. Its link to `/understanding-conditions#reduced-motion` is already resolved. | +| A11 | `/click-and-hover` | The heading "Use conditions for input capabilities and motion preferences" names motion preferences but its body no longer covers them. Rename it, or restore the coverage as a link to A1. | + +### 1.4 Non-reduced-motion defects inside the deferred section + +Left alone to keep the section byte-identical. Fix them as part of A1: + +- Its example references `full-reveal` / `simple-fade` effect IDs that are never defined. +- It has no `**Result:**` paragraph, unlike every other complete example on the site. +- Its fence is a bare `{ … }` fragment with no `// inside …` prefix, and contains a missing space in + `'less-motion':{`. + +--- + +## 2. Deferred cluster B — selectors and element resolution + +**Status: blocked on [`element-resolution-plan.md`](element-resolution-plan.md) (2026-07-30).** The +affected pages must not be rewritten until that plan is accepted or rejected, because the correct +wording differs depending on the outcome. + +Verified unchanged: `listItemSelector` mentions 19 → 19, and every wrong claim below is still present +verbatim. Only formatting was applied — heading case, link targets, `\+` unescaping, and the +`/using-lists` code-in-tables conversion, which reproduced the code exactly. + +### 2.1 The runtime truth + +| Claim | What the source says | +| :----------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `listItemSelector` filters binding | It is **never** consulted during element resolution. `_getElementsFromData()` (`src/core/add.ts:43-77`) branches only on `listContainer` and `selector`; with `listContainer` alone it returns `Array.from(container.children)` — **all** immediate children. The MutationObserver path processes every added/removed `HTMLElement` child with no filter. It is used in exactly three places: CSS selector generation (`getSelector(…, { addItemFilter: true })`, `src/core/Interact.ts:340`), the `closest()` lookup for **state effects** on lists (`src/handlers/effectHandlers.ts:112`), and the element-identity hash (`src/core/utilities.ts:31-33`). | +| `selector` picks the first match | `src/core/add.ts:64-72` — `root.querySelectorAll(data.selector)` returns **every** match, and each becomes a source/target. | +| `listContainer` + `selector` scoping | At bind time, `src/core/add.ts:57-59` runs `container.querySelectorAll(selector)` — a single query scoped to the **container**, matching any depth. The per-child `element.querySelector(selector)` form (`_queryItemElement`, `src/core/add.ts:79-85`) is used **only** for items discovered later by the MutationObserver. Real consequence: `listContainer: '.grid', selector: 'img'` binds to all images in `.grid`, including two inside one card, whereas a dynamically appended card contributes only its first `img`. | + +### 2.2 Wrong claims still live in the doc, by page + +**`/source-and-target-resolving`** — the chapter with the most errors: + +1. "`listItemSelector` is an **optional** filter. Use it only when a subset of the container's + children should participate…", plus the `.active` example config commented + `// only .active children become sources/targets`. +2. "When `listItemSelector` is omitted, **all** immediate children of the container participate." +3. Recap step 2 is **self-contradictory**: it says `listItemSelector` matches "every **descendant**", + while the prose above says immediate children. +4. Recap step 3: "use `querySelector` within the root to select first matching descendant" + (singular) — contradicts the `ElementIdentifier` comment `// refine to descendant(s)` and the + closing "The resulting element(s)". +5. "Interact runs `querySelector` inside each direct child of the container." +6. Recap step 2 treats `listItemSelector` and `selector` as mutually exclusive, but the `.active` + example and the `listContainer` prose imply they combine. +7. "Items added or removed later are tracked automatically" — unqualified. +8. Cosmetic: the `ElementIdentifier` snippet orders fields `key, selector, listContainer, +listItemSelector`; `src/types/config.ts` declares `key, listContainer, listItemSelector, selector`. + +**`/what-is-a-list`**: + +9. `listItemSelector` described as a general child filter (`.container > .listItemSelector`, + "provide it to include only those children") — contradicts `/using-lists`, which correctly scopes + it to CSS generation for state effects. +10. `listContainer` + `selector` described as "a child inside each item" (per-item scoping) — + contradicts `/using-lists`'s container scoping. +11. Key points: "`selector` targets a single child within a root" — singular vs `querySelectorAll`. +12. "`listContainer` resolves within the keyed ``" — entry-point-specific; + `/using-lists` correctly says "relative to the interaction's root element". +13. **The comparison table has lost its ✓/✗ markers.** Five cells begin with a bare leading space, so + both "Targets multiple elements?" and "A managed list?" read as unanswered, and the `(filtered)` + cell answers nothing. Restore the markers, or convert the two yes/no columns to explicit + "Yes/No" text, which survives copy-paste better. + +### 2.3 The fix, once the plan lands + +- Make all three pages state **identical** resolution rules. `/using-lists`'s three-property table is + the most accurate description in the document — promote its wording: + _"Narrows which direct children count as list items when Interact generates CSS (for `transition` / + state effects). Use it when the container also holds elements that are not items."_ +- Add an explicit warning: **`listItemSelector` does not restrict which children receive JS-driven + triggers or animations — all immediate children of `listContainer` are bound.** +- Delete or rewrite the `.active` example, which teaches behaviour the runtime does not implement. +- State the container-scoped `querySelectorAll` rule as the primary behaviour. Either document the + per-item difference for MutationObserver-added children, or (preferred) file it as a runtime + inconsistency and document only the stable rule. +- Note the practical consequence of `querySelectorAll`: `selector: '.card'` attaches the trigger to + _every_ `.card` in the root, not just the first. +- Also fix while in the area: the two same-page links on `/source-and-target-resolving` are written as + full slug + anchor (`/source-and-target-resolving#…`); switch to bare `#anchor` if the site builder + prefers that. And that page's four pre-existing examples were left without `### Example:` headings, + because adding headings would have changed the anchor set. + +### 2.4 Blocked upstream items in this cluster + +These two are deliberately **not** fixed — they are the runtime/rules side of the same decision: + +| # | File | Issue | +| :-- | :--------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| B1 | `rules/full-lean.md:692`, `:202`, `:356` | Source resolution claims `listItemSelector` filters which children become sources. Wrong (§2.1). Three sites, same framing. | +| B2 | `src/core/add.ts` | `listContainer` + `selector` resolves differently at initial bind (`container.querySelectorAll`) than for MutationObserver-added items (`child.querySelector`). Likely a bug; documenting it as-is would document an inconsistency. | + +--- + +## 3. Open decision — release gating of 2.6.0 API + +**The original audit was wrong on this point.** It claimed dual-casing (PR #281) and the plugin API +(PR #275) "shipped in `@wix/interact` 2.5.5". They did not. `CHANGELOG.md` places both under +**`@wix/interact [2.6.0] — unreleased`**; the published version is **2.5.6**. + +The site doc now documents **master, with no version pins** (an explicit decision). One consequence +needs resolving before publishing: + +| Surface | On master (2.6.0) | On published 2.5.6 | +| :--------------------------------------------- | :---------------------------------------------------- | :----------------------------------------------------------------------------- | +| `generate(config, { useFirstChild: … })` | Options bag, normalised by `normalizeGenerateOptions` | Second arg is a plain **boolean**; an object is truthy → `useFirstChild: true` | +| `Interact.use()` / `$` / `/plugins` page | Public API | Does not exist | +| "either casing works" notes | True | camelCase state properties are written verbatim into CSS and silently dropped | + +The options-bag form is documented on `/html-integration`, `/named-effects`, `/using-lists` and +`/the-final-result`. **Options:** ship the site alongside the 2.6.0 release (no doc change needed); +add "Requires `@wix/interact` 2.6.0" notes to those surfaces; or revert to the legacy positional +boolean until release. diff --git a/interact-documentation-site.md b/interact-documentation-site.md new file mode 100644 index 00000000..cfead655 --- /dev/null +++ b/interact-documentation-site.md @@ -0,0 +1,6768 @@ +# About Interact + +Interact is a web animation and interaction library for building interactive experiences with motion. It helps developers, designers, and anyone building websites or apps create responsive, high-performance motion experiences. + +## What is Interact? + +Interact provides a structured way to connect user actions with visual changes — from simple UI animations to complex, coordinated page experiences. + +Interact uses a declarative JavaScript API that describes interactions as structured configuration rather than imperative animation code. This makes interactions easier to write, review, and maintain as projects grow. + +The same structure is also easy for large language models (LLMs) to understand and generate. Because interactions are expressed as intent rather than low-level animation instructions, AI can reliably create, modify, and extend them while keeping the configuration predictable and readable. Interact ships [agent rules](https://github.com/wix/interact/tree/master/packages/interact/rules) that teach a coding agent the configuration format and the available triggers and effects. + +An `InteractConfig` is plain data and fully JSON-serializable, with one exception: a `customEffect` holds a JavaScript function, so any configuration that uses one cannot round-trip through JSON. The same applies to `offsetEasing` when you pass a function instead of an easing name. + +With Interact, you define the relationship between: + +- **Triggers** — what starts the interaction +- **Effects** — what animation should happen +- **Elements** — what should respond + +Instead of implementing each animation and coordinating it with triggers in imperative code, you describe the relationship between triggers, effects, and elements in a structured configuration. + +### Example: a hero section that animates on entry + +Start from the intent. When a visitor scrolls the hero section into view: + +- Reveal the headline +- Animate the image +- Stagger the cards + +Interact translates that behavior into a structured configuration: + +```ts +const config: InteractConfig = { + interactions: [ + { + key: 'hero', + trigger: 'viewEnter', + effects: [ + { key: 'hero-headline', effectId: 'headline-reveal' }, + { key: 'hero-image', effectId: 'hero-image-animate' }, + ], + sequences: [ + { + effects: [ + { + key: 'hero-cards', + listContainer: '.cards-list', + listItemSelector: '.card', + effectId: 'card-reveal', + }, + ], + offset: 120, + }, + ], + }, + ], + effects: { + 'headline-reveal': { + namedEffect: { type: 'RevealIn', direction: 'bottom' }, + duration: 600, + triggerType: 'once', + }, + 'hero-image-animate': { + namedEffect: { type: 'FadeIn' }, + duration: 800, + easing: 'ease-out', + triggerType: 'once', + }, + 'card-reveal': { + namedEffect: { type: 'FloatIn', direction: 'bottom' }, + duration: 500, + triggerType: 'once', + }, + }, +}; +``` + +Every `effectId` in `interactions` points at an entry in the top-level `effects` registry, which is where the animation itself is defined once and reused. The `namedEffect` values above come from `@wix/motion-presets`. + +**Result:** When the hero section scrolls into view, the headline reveals upward, the image fades in over 800 ms, and each card floats up in turn, 120 ms apart. + +This is an interaction: a reusable definition of behavior that describes when something happens, what should happen, and which elements should respond. + +## From motion infrastructure to motion intelligence + +Animation libraries gave developers powerful tools to create motion. Interact introduces a structured interaction model that makes motion easier to create, understand, and scale. + +The same structure that makes Interact easier for developers also makes it easier for AI systems to work with. Interact configurations are predictable, semantic, and based on intent. This allows large language models to generate, modify, and reason about interactions without having to reconstruct complex imperative animation code. + +This predictable, declarative structure gives developers and AI a shared way to describe interactive behavior. Given a plain-language prompt: + +> Create a hero animation. When the section enters the viewport, reveal the headline, animate the image, and stagger the feature cards. + +An agent produces the same configuration shape you would write by hand: + +```ts +const config: InteractConfig = { + interactions: [ + { + key: 'hero', + trigger: 'viewEnter', + effects: [ + { key: 'hero-headline', effectId: 'headline-reveal' }, + { key: 'hero-image', effectId: 'hero-image-animate' }, + ], + sequences: [ + { + effects: [{ key: 'hero-cards', effectId: 'card-reveal' }], + offset: 120, + }, + ], + }, + ], + effects: { + 'headline-reveal': { + namedEffect: { type: 'RevealIn', direction: 'bottom' }, + duration: 600, + triggerType: 'once', + }, + 'hero-image-animate': { + namedEffect: { type: 'FadeIn' }, + duration: 800, + easing: 'ease-out', + triggerType: 'once', + }, + 'card-reveal': { + namedEffect: { type: 'FloatIn', direction: 'bottom' }, + duration: 500, + triggerType: 'once', + }, + }, +}; +``` + +## Built for modern web motion + +Interact combines: + +- Powerful animation capabilities +- A declarative way to define interactions +- Reusable effects and sequences +- Responsive behavior across devices +- High-performance execution through `@wix/motion` + +Interact describes the interaction; `@wix/motion` executes the animation. Together, they provide a foundation for building the next generation of motion experiences. + +Ready to build? [Create your first interaction](/my-first-interaction). + +## See also + +- [Installation and entry points](/installation-and-entry-points) +- [My first interaction](/my-first-interaction) +- [The config object](/the-config-object) +- [Named effects](/named-effects) + +# Getting started + +# Installation and entry points + +Interact ships as one package with three entry points. Install `@wix/interact`, pick the entry point that matches your stack, and write the same configuration either way. + +## Install + +Install `@wix/interact` using your project's package manager: + +```bash +npm install @wix/interact +``` + +Use `yarn add @wix/interact` or `pnpm add @wix/interact` instead when appropriate. + +`@wix/motion` is a dependency of `@wix/interact` and is installed automatically. Do not install it separately. + +## Optional: ready-made named effects + +Install [`@wix/motion-presets`](https://github.com/wix/interact/tree/master/packages/motion-presets) to use the ready-made named effect library: + +```bash +npm install @wix/motion-presets +``` + +Register the presets once, before creating an Interact instance: + +```ts +import { Interact } from '@wix/interact/web'; +import * as presets from '@wix/motion-presets'; + +Interact.registerEffects(presets); +``` + +You do not need this package when you use only `keyframeEffect` or `customEffect`. + +## Entry points + +Choose the entry point that matches your project: + +| Entry point | Import | Binding mechanism | Use when | +| :-------------------- | :----------------------------------------------------------------------- | :--------------------------------------------------------------------- | :------------------------------------------------------------- | +| `@wix/interact/web` | `import { Interact, generate } from '@wix/interact/web';` | `` custom element | Static HTML, Web Components, SSR, or most non-React frameworks | +| `@wix/interact/react` | `import { Interact, Interaction, generate } from '@wix/interact/react';` | `` component, or `createInteractRef()` | React, Next.js, or Remix | +| `@wix/interact` | `import { Interact, add, remove, generate } from '@wix/interact';` | `add(element, 'hero')` and `remove('hero')` called by you | Vanilla JavaScript or manual DOM management | + +### Web Components + +```ts +import { Interact, generate } from '@wix/interact/web'; +``` + +```html + +
    Hello, animated world!
    +
    +``` + +### React + +```tsx +import { Interact, Interaction, generate } from '@wix/interact/react'; +``` + +```tsx + + Hello, animated world! + +``` + +### Vanilla JavaScript + +```ts +import { Interact, add, remove, generate } from '@wix/interact'; + +add(document.querySelector('#hero'), 'hero'); +``` + +All three entry points use the same configuration format, triggers, and effects, and all three export the same `Interact` class, the same `generate()` function for build-time or server-side CSS, and the same types. They differ only in how elements are connected to interactions. + +## Optional: configuration validation + +For agent-generated configurations, or for build-time and CI checks, install [`@wix/interact-validate`](https://github.com/wix/interact/tree/master/packages/interact-validate) as a development dependency: + +```bash +npm install --save-dev @wix/interact-validate +``` + +The validator checks configurations statically, without a browser or a DOM. + +## See also + +- [About Interact](/about-interact) +- [My first interaction](/my-first-interaction) +- [HTML integration](/html-integration) +- [The config object](/the-config-object) +- [`@wix/interact-validate`](https://github.com/wix/interact/tree/master/packages/interact-validate) + +# My first interaction + +An interaction connects something the visitor does to something on the page that animates. This page builds the smallest complete one: a card whose logo grows slightly while the pointer is over it. + +The same interaction is built three times below, once per entry point, so you can follow the variant that matches your stack. Each variant runs through the same four steps: add the markup, define the config, create the runtime, clean up. + +## Set up an interaction + +An interaction is a plain object with three parts: + +- `key` — the string that binds the interaction to an element on the page. +- `trigger` — what starts it. +- `effects` — an array of what animates, and for how long. + +Interactions live in the `interactions` array of an `InteractConfig`, which you hand to `Interact.create()`. Nothing else is required: the `effects`, `sequences`, and `conditions` registries are optional and can stay out of the config until you need them. + +### Types of triggers + +Interact ships eight triggers: `hover` and `click`, their keyboard-accessible counterparts `interest` and `activate`, `viewEnter` for entrance animations, `viewProgress` and `pointerMove` for effects that follow scroll or pointer position, and `animationEnd` for chaining one effect after another. This page uses `hover`. See [What is a trigger?](/what-is-a-trigger). + +### Types of effects + +An effect describes what animates. Pick exactly one kind per effect: `keyframeEffect` for keyframes you write yourself, `namedEffect` for a ready-made animation from `@wix/motion-presets`, `customEffect` for a callback you drive frame by frame, or `transition` / `transitionProperties` for CSS transitions between style states. This page uses `keyframeEffect`. See [What are effects?](/what-are-effects). + +> **Warning:** Do not scale or move the element a `hover` trigger is bound to. As it grows it slides out from under the pointer, the browser fires a leave event, the animation reverses, the element settles back under the pointer, and the whole thing flickers. Keep the hovered element still and animate a child instead — every example below scales the inner `.logo` and leaves the `.logo-card` that listens for hover exactly where it is. See [Source and target resolving](/source-and-target-resolving). + +All three variants share the same styles: + +```css +.logo-card { + display: inline-block; + padding: 24px; +} + +.logo { + display: block; + width: 120px; +} +``` + +## React (`@wix/interact/react`) + +### 1. Add the markup + +Wrap the element you want to bind in the `Interaction` component. It renders the tag you pass in `tagName` and sets `data-interact-key` from `interactKey`, so `` renders a `
    `. + +```tsx +import { Interaction } from '@wix/interact/react'; + +function LogoCard() { + return ( + + Logo + + ); +} +``` + +### 2. Define the config + +```ts +import type { InteractConfig } from '@wix/interact/react'; + +const config: InteractConfig = { + interactions: [ + { + key: 'logo-card', // SOURCE — stays put, so the hover area never moves + trigger: 'hover', + effects: [ + { + selector: '.logo', // TARGET — the child that scales + keyframeEffect: { + name: 'logo-grow', + keyframes: [{ scale: 1 }, { scale: 1.05 }], + }, + duration: 300, + easing: 'ease-out', + fill: 'both', + }, + ], + }, + ], +}; +``` + +`fill: 'both'` keeps the last frame applied for as long as the pointer stays over the card. + +### 3. Create the runtime + +Call `Interact.create()` inside a `useEffect()` hook so it only ever runs in the browser, never during server rendering. + +```tsx +import { useEffect } from 'react'; +import { Interact, Interaction } from '@wix/interact/react'; + +export default function App() { + useEffect(() => { + Interact.create(config); + }, []); + + return ( + + Logo + + ); +} +``` + +### 4. Clean up + +Keep the instance `Interact.create()` returns and destroy it from the effect's cleanup function, so the interaction is torn down when the component unmounts — and re-created cleanly when React remounts it in ``. + +```tsx +useEffect(() => { + const instance = Interact.create(config); + + return () => instance.destroy(); +}, []); +``` + +**Result:** Moving the pointer anywhere over the card grows the logo by 5% over 300ms, and moving it away reverses the animation. The card itself never moves, so the pointer stays inside it and the effect does not flicker. + +## Web Components (`@wix/interact/web`) + +### 1. Add the markup + +Wrap the content in `` and give it a `data-interact-key`. The element binds itself as soon as it connects to the DOM, so there is no binding call to write. It must contain at least one child element. + +```html + + +
    + +
    +
    + + + +``` + +### 2. Define the config + +```ts +import type { InteractConfig } from '@wix/interact/web'; + +const config: InteractConfig = { + interactions: [ + { + key: 'logo-card', // SOURCE — stays put, so the hover area never moves + trigger: 'hover', + effects: [ + { + selector: '.logo', // TARGET — the child that scales + keyframeEffect: { + name: 'logo-grow', + keyframes: [{ scale: 1 }, { scale: 1.05 }], + }, + duration: 300, + easing: 'ease-out', + fill: 'both', + }, + ], + }, + ], +}; +``` + +### 3. Create the runtime + +Importing from `@wix/interact/web` is what registers the `` custom element; `Interact.create()` starts the runtime and returns the instance. + +```ts +import { Interact } from '@wix/interact/web'; + +const instance = Interact.create(config); +``` + +### 4. Clean up + +Hold on to the instance and call `instance.destroy()` when the markup goes away — on a client-side route change, for example. `Interact.destroy()` tears down every instance at once. + +```ts +instance.destroy(); +``` + +**Result:** The logo inside the card scales to 1.05 while the pointer is over the card and scales back when it leaves. Because `` binds on connect, markup added later — by a template, a partial, or another framework — starts working without any extra wiring. + +## Vanilla JavaScript (`@wix/interact`) + +The vanilla entry point leaves binding to you: call `add(element, key)` once the element exists in the DOM. + +### 1. Add the markup + +No wrapper element is needed here — a plain container is enough. + +```html + +
    + +
    + + + +``` + +### 2. Define the config + +```ts +import type { InteractConfig } from '@wix/interact'; + +const config: InteractConfig = { + interactions: [ + { + key: 'logo-card', // SOURCE — stays put, so the hover area never moves + trigger: 'hover', + effects: [ + { + selector: '.logo', // TARGET — the child that scales + keyframeEffect: { + name: 'logo-grow', + keyframes: [{ scale: 1 }, { scale: 1.05 }], + }, + duration: 300, + easing: 'ease-out', + fill: 'both', + }, + ], + }, + ], +}; +``` + +### 3. Create the runtime + +```ts +import { Interact, add } from '@wix/interact'; + +const instance = Interact.create(config); +const card = document.querySelector('.logo-card'); + +if (card) { + add(card, 'logo-card'); +} +``` + +### 4. Clean up + +`remove(key)` unbinds a single key, which is what you want when one element disappears. `instance.destroy()` tears down everything the config bound. + +```ts +import { remove } from '@wix/interact'; + +remove('logo-card'); +instance.destroy(); +``` + +**Result:** The same hover animation runs, but you decide exactly when the element is bound — useful when the markup arrives from a fetch, a template engine, or another framework's render. + +> **Tip:** Instead of writing your own keyframes, you can use [named effects](/named-effects) — ready-made animations from the [`@wix/motion-presets`](https://github.com/wix/interact/tree/master/packages/motion-presets) package. Register them with `Interact.registerEffects()` before calling `Interact.create()`, then swap `keyframeEffect` for `namedEffect`. + +## See also + +- [Installation and entry points](/installation-and-entry-points) +- [HTML integration](/html-integration) +- [The config object](/the-config-object) +- [Click and hover](/click-and-hover) +- [Source and target resolving](/source-and-target-resolving) + +# HTML integration + +Interact ships three entry points so you can drop it into any stack — a framework-free page, a React app, or a Web Components setup. All three share the same `Interact` class, the same `generate()` CSS helper, the same [`InteractConfig`](/the-config-object) shape, and the same [triggers](/what-is-a-trigger) and [effects](/what-are-effects). The only thing that changes between them is **how a DOM element gets bound to an interaction `key`**. + +Install the package and compare the entry points on [Installation and entry points](/installation-and-entry-points); this page picks up once it is installed. + +> **Note:** `@wix/motion` is a runtime dependency of `@wix/interact` — your package manager installs it for you, so you never add it yourself. [Named effects](/named-effects) are the exception: they live in the optional `@wix/motion-presets` package, which you install alongside Interact. + +## The integration lifecycle + +Regardless of the entry point, every integration follows the same four steps — the second one only when your config uses named effects: + +1. **Define a config** — an [`InteractConfig`](/the-config-object) object describing your interactions. +2. **Register named effects** _(optional)_ — if your config uses [`namedEffect`](/named-effects), call `Interact.registerEffects(...)` **before** the next two steps. Both `generate()` and `Interact.create()` read the `effects` registry at the moment they run. See [Named effects](#named-effects-registereffects). +3. **Generate CSS** — call `generate(config, options)` at build time or on the server, and inject the result into ``. This prepares `@keyframes`, `view-timeline` declarations, transitions, and — for entrance animations — prevents a flash of unstyled content (FOUC). +4. **Create the runtime** — call `Interact.create(config)` on the client to start observing triggers and running effects. + +The only per-framework difference is how each keyed element is bound to the runtime. + +```text +config ─┬─► generate(config, options) ─► CSS → (build time / SSR) + └─► Interact.create(config) ───► triggers → effects (client) +``` + +### `generate()` options + +`generate()` takes the config plus one optional options bag: + +```ts +import { generate } from '@wix/interact'; + +const css = generate(config, { useFirstChild: false }); +``` + +| Option | Type | Default | Description | +| :-------------- | :--------------------- | :------ | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `useFirstChild` | `boolean` | `true` | Emit rules that target the first child of the keyed element. `true` for the web entry point, where `` wraps the content; `false` for React and vanilla, where the keyed element is the animated element itself. | +| `plugins` | `InteractPluginStyles` | — | Map of plugin name → SSR style generator. For every `$` field in the config, the matching generator is called and its CSS is appended. See [Plugins](/plugins). | + +> **Warning:** `useFirstChild` defaults to `true`. In React and vanilla integrations you have to pass `{ useFirstChild: false }` explicitly — otherwise every generated rule is scoped to `> :first-child` of the keyed element, nothing matches, and no CSS applies. + +> **Note:** Passing a bare boolean — `generate(config, false)` — is a legacy alias for `{ useFirstChild: false }`. It still works; new code should use the options bag. + +--- + +## Web (Custom Elements) + +The `@wix/interact/web` entry point registers the `` custom element. Wrap each interactive region in `` and give it a `data-interact-key` that matches your interaction's `key`. Binding happens automatically when the element connects — no manual `add()` call needed. + +```ts +import { Interact, generate, type InteractConfig } from '@wix/interact/web'; +// Optional — only if your config uses namedEffect +import * as presets from '@wix/motion-presets'; + +const config: InteractConfig = { + interactions: [ + { + key: 'hero', + trigger: 'viewEnter', + effects: [ + { + duration: 1000, + keyframeEffect: { + name: 'fade', + keyframes: [{ opacity: 0 }, { opacity: 1 }], + }, + }, + ], + }, + ], +}; + +// Optional — register named effects before generate()/create() +Interact.registerEffects(presets); + +// Render CSS (e.g. during SSR) — web keeps the `useFirstChild: true` default +const interactCSS = generate(config, { useFirstChild: true }); + +// Start the runtime on the client +Interact.create(config); +``` + +```html + + + + + + +
    +

    Welcome

    +
    +
    + +``` + +### Key points + +- `data-interact-key` **must** be unique within the page and match the interaction's `key`. +- `` **must** wrap at least one child element — Interact targets its `:first-child` by default. +- Pass `generate(config, { useFirstChild: true })` for the web entry point so `:first-child` selectors are emitted correctly. It is the default, but spelling it out keeps the contrast with your React and vanilla call sites obvious. + +### Loading from a CDN + +For environments without a package manager or build step, load the pre-bundled module straight from a CDN with a native ES module ` +``` + +```html + +
    Hello, animated world!
    +
    +``` + +> **Tip:** A major-version range such as `@wix/interact@2` lets the CDN serve patches and new features while holding back breaking changes. Pin an exact version instead when you need byte-for-byte reproducible output. + +> **Warning:** A `type="module"` script is deferred, so CSS generated this way lands after the first paint. Keep entrance content hidden until then — see [preventing FOUC](/html-integration#preventing-fouc). + +--- + +## React + +The `@wix/interact/react` entry point adds the `` component. It renders the tag you specify, stamps the `data-interact-key` attribute, and binds/unbinds the element automatically through a ref — so you don't call `add()`/`remove()` yourself. It is the preferred way to bind elements in React. + +Generate the CSS **once, outside the component**: `generate()` walks the entire config, and calling it in the component body repeats that work on every render. Create the runtime inside `useEffect`, so it only runs on the client, and tear it down on cleanup. + +```tsx +import { useEffect } from 'react'; +import { Interact, Interaction, generate } from '@wix/interact/react'; +import type { InteractConfig } from '@wix/interact/react'; + +const config: InteractConfig = { + interactions: [ + { + key: 'hero', + trigger: 'viewEnter', + effects: [ + { + duration: 1000, + keyframeEffect: { + name: 'fade', + keyframes: [{ opacity: 0 }, { opacity: 1 }], + }, + }, + ], + }, + ], +}; + +// Module scope — runs once. React renders the keyed element directly, so `useFirstChild` is false. +const interactCSS = generate(config, { useFirstChild: false }); + +export function App() { + useEffect(() => { + const instance = Interact.create(config); + return () => instance.destroy(); + }, []); + + return ( + <> + + + {/* renders
    and binds it */} + +

    Welcome

    +
    + + ); +} +``` + +> **Note:** `Interact.create()` is a no-op during server rendering. It calls `init()` internally, which returns immediately when `typeof window === 'undefined'` — and also when `window.customElements` is unavailable, so nothing binds in an environment without custom-element support even on the client. Calling `create()` from `useEffect` keeps it on the client, where both checks pass. + +> **Tip:** If the config is built at runtime (from per-page data, for example), wrap the call in `useMemo(() => generate(config, { useFirstChild: false }), [config])` rather than hoisting it. Better still, run `generate()` in your build step or SSR pass and ship the result as a stylesheet. + +### `` props + +| Prop | Type | Default | Description | +| :------------ | :---------------------------- | :----------- | :----------------------------------------------------------------------- | +| `tagName` | `keyof JSX.IntrinsicElements` | **Required** | The HTML tag to render (e.g. `'section'`, `'div'`). | +| `interactKey` | `string` | **Required** | Unique key matching an interaction's `key`. | +| `children` | `React.ReactNode` | — | Content rendered inside the tag. | +| `ref` | `React.Ref` | — | Forwarded to the rendered DOM element, alongside Interact's binding ref. | +| `...rest` | — | — | Any valid props for `tagName` (`className`, `style`, event handlers). | + +### Manual binding with `createInteractRef` + +Reach for `createInteractRef` only when you must render the element yourself — for example when passing a `ref` into a third-party component. Otherwise use ``. + +`createInteractRef(key)` returns a **new** ref callback every time it is called. Creating it during render therefore hands React a different callback on each render, so React detaches the old ref (which calls `remove(key)`) and attaches the new one (which calls `add(element, key)`) — churning the binding on every render. Keep the callback stable with `useRef` or `useMemo`: + +```tsx +import { useRef } from 'react'; +import { createInteractRef } from '@wix/interact/react'; + +function Hero() { + const interactRef = useRef(createInteractRef('hero')); + + return ( +
    +

    Welcome

    +
    + ); +} +``` + +Write `data-interact-key` into your own markup, as above. The generated CSS is scoped to `[data-interact-key="hero"]`, and while `add()` sets the attribute when it binds, that happens only once the ref runs — an attribute already in your JSX is there from the first paint. `` handles this for you. + +### Key points + +- `` is the preferred binding mechanism; `createInteractRef` is the escape hatch. +- Always call `Interact.create()` inside `useEffect` and `instance.destroy()` in its cleanup function. +- Pass `generate(config, { useFirstChild: false })` for React — the keyed element is rendered directly, without an `` wrapper. +- `tagName` must be a valid HTML tag; `interactKey` must be unique within the page. + +--- + +## Vanilla JS + +Use the base `@wix/interact` entry point when you manage your own DOM. After the elements exist on the page, bind each one to its interaction `key` with `add()`. + +```ts +import { Interact, add, generate, type InteractConfig } from '@wix/interact'; + +const config: InteractConfig = { + interactions: [ + { + key: 'hero', + trigger: 'viewEnter', + effects: [ + { + duration: 1000, + keyframeEffect: { + name: 'fade', + keyframes: [{ opacity: 0 }, { opacity: 1 }], + }, + }, + ], + }, + ], +}; + +// 1. Inject generated CSS (ideally done at build time / on the server) +document.head.insertAdjacentHTML( + 'beforeend', + ``, +); + +// 2. Start the runtime +const instance = Interact.create(config); + +// 3. Bind each element to its key +add(document.querySelector('.hero'), 'hero'); +``` + +```html +
    +

    Welcome

    +
    +``` + +### API + +| Function | Description | +| :------------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `add(element, key)` | Binds a DOM element to an interaction `key`. Call it **after** the element is in the DOM. | +| `remove(key)` | Tears down the whole controller registered for `key` — every interaction bound to that element, not a single one — and disconnects its triggers. To bind it again, call `add(element, key)` again. | + +### Key points + +- `add()` must run after the target element exists in the DOM. +- For content that appears later (modals, infinite lists, route changes), call `add()` when the element mounts and `remove()` when it unmounts. +- `remove()` has no partial form: you cannot detach one interaction from a key while leaving the others attached. + +--- + +## Named effects (`registerEffects`) + +`namedEffect` animations ship in `@wix/motion-presets` and have to be registered before Interact can resolve them: + +```ts +import { Interact } from '@wix/interact/web'; +import * as presets from '@wix/motion-presets'; + +Interact.registerEffects(presets); +``` + +Three rules matter when wiring this into a build: + +- **Register before you generate or create.** The registry is a module-level singleton that both `generate()` and `Interact.create()` read when they run. CSS generated before a preset was registered simply omits that animation — Interact logs a console warning rather than throwing. +- **Re-run `generate()` for every config after registering.** Registering more effects later does not update CSS you have already produced. +- **Register in both processes.** Your build/SSR step and your client bundle are separate JavaScript environments with separate registries. Registering only at build time leaves the runtime unable to resolve the effect, and registering only on the client leaves the CSS incomplete. + +> **Tip:** Import only the effects your config uses — `Interact.registerEffects({ FadeIn, ParallaxScroll })` — so your bundler can drop the rest. + +For the full catalog and each effect's options, see [Named effects](/named-effects). + +--- + +## Generating CSS + +`generate(config, options?)` produces the complete CSS for **all** interactions in a config in one pass — `@keyframes`, animation and transition custom properties, `view-timeline` declarations, state-selector rules, and, for entrance animations, the FOUC-prevention rules described below. Run it at build time or on the server and embed the output before first paint. + +```ts +import { generate } from '@wix/interact/web'; + +const css = generate(config, { useFirstChild: true }); +``` + +`generate()` is exported from all three entry points and needs no DOM, so it is safe to call from a build script or a server renderer. + +### Parameters + +The second argument is a `GenerateOptions` bag. + +| Name | Type | Default | Description | +| :---------------------- | :--------------------- | :------ | :----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `config` | `InteractConfig` | — | The same config you pass to `Interact.create()`. | +| `options.useFirstChild` | `boolean` | `true` | `true` for the web (``) entry point; `false` for the vanilla and React integrations. Must match `useCustomElement` on `Interact.create()`. | +| `options.plugins` | `InteractPluginStyles` | — | Map of plugin name to a build-time style generator. Required to server-render the styles for any `$`-prefixed plugin field in the config. See [Plugins](/plugins). | + +> **Note:** `generate(config, true)` — a bare boolean as the second argument — is still supported as the legacy `useFirstChild` signature. + +> **Tip:** When the config uses `namedEffect`, call `Interact.registerEffects()` before `generate()`, otherwise the named effect cannot be resolved into keyframes. + +### Embedding the generated CSS + +The generated CSS must reach the browser before the elements it guards are painted. Any of these three placements works: + +```html + + + + + + + + + + + + + + + + +``` + +`blocking="render"` is the strongest guarantee: the browser will not paint until that stylesheet has been applied. Use it when the CSS cannot be inlined in ``. + +> **Warning:** If you generate CSS in the browser at runtime, inject it before the matching `Interact.create()` call and before you reveal any initially hidden content. Runtime generation cannot fully prevent FOUC on its own. + +--- + +## Preventing FOUC + +An entrance animation starts from a hidden or offset frame — `opacity: 0`, a `translateY`, a `scale`. Between first paint and the moment the animation engine applies that first frame, the element renders in its final, visible state: a **flash of unstyled content (FOUC)**. The CSS from `generate()` closes that window by holding the element in a neutral hidden state until the animation actually starts. + +FOUC prevention is fully automatic. You do not add an attribute, a class, or an inline style to opt in — generating the CSS and embedding it before first paint is the whole mechanism. + +### What the generated CSS emits + +For a qualifying entrance animation, `generate()` emits two guarded rules alongside the normal ones. For an interaction keyed `hero` with a fade-in: + + +```css +[data-interact-key="hero"]:not([data-interact-enter]) { + visibility: hidden; + transform: none !important; + translate: none !important; + scale: none !important; + rotate: none !important; +} +[data-interact-key="hero"]:not([data-interact-enter="done"]) { + --animation-0-8diz7tv2pl: fadeIn 600ms 0ms ease-out 1 paused; + /* … */ +} +``` + +The first rule is the guard. It hides the element and neutralizes the four transform properties so nothing renders in a half-applied position. The four `!important` flags are deliberate: without them an author stylesheet could re-apply a `transform` and defeat the guard. They are expected in DevTools and are not a bug. + +The second rule carries the animation custom properties and stops applying once the animation reports `done`, so a finished entrance leaves no animation shorthand behind. + +### `data-interact-enter` + +`data-interact-enter` is written by the runtime and read by the generated CSS. It is **read-only for authors** — never set it in your markup or your own JavaScript. + +| Value | Set when | Consequence | +| :--------- | :--------------------------------------------- | :--------------------------------------------------------------------------------------------- | +| _(absent)_ | Initial state, before the animation plays. | Both rules apply — the element is hidden and the animation is staged. | +| `start` | The runtime plays the animation. | The hide rule stops matching, so the element becomes visible and animates. | +| `done` | A CSS-driven animation finishes or is aborted. | Both rules stop matching; the element retains whatever the animation's `fill` leaves in place. | + +It is not exclusive to `viewEnter`. Event-triggered time effects with `triggerType: 'repeat'` or `'once'` also clear the attribute each time they replay and re-set it to `done` when the animation finishes or aborts — clearing it is what re-arms the animation custom properties in the second rule so a CSS animation can run again. + +### When FOUC prevention does not apply + +The guard rules are emitted only when all three of these hold: + +- the trigger is `viewEnter`, +- the effect's `triggerType` is `once` (the default), and +- the effect targets the same element the interaction is keyed to. + +Everything else — `repeat`, `alternate`, `state`, and any `viewEnter` effect that animates a different element — gets no guard rule, and the target renders in its natural state until the animation runs. + +If that first frame would be visibly wrong, apply the starting keyframe yourself and set `fill: 'both'` so the animation holds both ends of the range: + +```html +
    …
    +``` + +```ts +// inside interactions[] +{ + key: 'card', + trigger: 'viewEnter', + effects: [ + { + duration: 600, + fill: 'both', + triggerType: 'repeat', + keyframeEffect: { + name: 'fadeIn', + keyframes: [{ opacity: 0 }, { opacity: 1 }], + }, + }, + ], +} +``` + +**Result:** the card is transparent from first paint, fades in each time it scrolls into view, and stays transparent between repeats instead of flashing to full opacity. + +> **Note:** Scroll-driven effects (`viewProgress`) need no FOUC handling. Their progress is bound to scroll position, so the first painted frame is already the correct one. + +> **Note:** `generate()` covers the whole config, not just entrance triggers. Always embed its output, even on a page that only uses hover and click. + +--- + +## Static API reference + +Each `Interact.create(config)` call returns an `Interact` instance. Keep a reference if you need to dynamically bind elements, or to destroy that instance later. + +| Member | Type | Default | Description | +| :---------------------------------- | :------------------------------ | :------ | :------------------------------------------------------------------------------------------------------------------------------------------- | +| `Interact.create(config, options?)` | `Interact` | — | Initializes a runtime for the config and returns the instance. Multiple configs create separate instances. | +| `Interact.registerEffects(effects)` | `void` | — | Registers `namedEffect` presets into the `effects` registry. Call before `generate()` and `create()` when using named effects. | +| `Interact.setup(options)` | `void` | — | Sets global defaults for `viewProgress`, `pointerMove` and `viewEnter`, and toggles the accessible trigger upgrades. Call before `create()`. | +| `Interact.use(name, plugin)` | `void` | — | Registers a plugin under `name`, invoked for a matching `$` config field. Call before `create()`. | +| `Interact.getPlugin(name)` | `InteractPlugin` \| `undefined` | — | Returns the plugin registered under `name`. | +| `Interact.getPluginsNames()` | `Set` | — | Returns the names of all registered plugins. | +| `Interact.destroy()` | `void` | — | Tears down **all** instances (e.g. on full page navigation). | +| `instance.destroy()` | `void` | — | Tears down a single instance created by `Interact.create()`. | +| `Interact.forceReducedMotion` | `boolean` | `false` | force reduced-motion behavior regardless of the OS setting. | +| `Interact.allowA11yTriggers` | `boolean` | `true` | Upgrades `hover` to `interest` and `click` to `activate` when triggers are bound. See below. | + +### `Interact.create(config, options)` + +```ts +const instance = Interact.create(config, { useCustomElement: true }); +``` + +| Name | Type | Default | Description | +| :------------------------- | :--------------- | :----------------- | :---------------------------------------------------------------------------------------- | +| `config` | `InteractConfig` | — | The config to run. | +| `options.useCustomElement` | `boolean` | entry-point driven | Selects custom-element mode. Must match the `useFirstChild` value passed to `generate()`. | + +`useCustomElement` defaults to `true` when the `@wix/interact/web` entry point has been imported — importing it is what registers `` — and `false` otherwise. Pass it explicitly only when you import the web entry point but do not wrap your markup in ``. + +> **Note:** `Interact.create()` is a no-op where there is no `window` (server rendering) or no `window.customElements`. Generating CSS on the server and creating the runtime in the browser is the intended split. + +### `Interact.setup(options)` + +Configure global trigger defaults before creating any instances: + +```ts +Interact.setup({ + viewEnter: { threshold: 0.25, inset: '10%' }, + scrollOptionsGetter: () => ({ + /* … */ + }), + pointerOptionsGetter: () => ({ + /* … */ + }), + allowA11yTriggers: true, +}); +``` + +| Name | Type | Default | Description | +| :--------------------- | :----------------------------- | :------ | :--------------------------------------------------------------------------------------- | +| `viewEnter` | `Partial` | `{}` | Default `threshold`, `inset` and `useSafeViewEnter` for every `viewEnter` trigger. | +| `scrollOptionsGetter` | `() => Partial` | — | Returns options merged into every scroll controller Interact creates for `viewProgress`. | +| `pointerOptionsGetter` | `() => Partial` | — | Returns options merged into every pointer controller Interact creates for `pointerMove`. | +| `allowA11yTriggers` | `boolean` | `true` | Toggles the accessible trigger upgrades described below. | + +The two getters are called each time Interact builds a scroll or pointer controller, and their result is spread last — so they also override Interact's own defaults, such as the scroll `root`. Use them for global smoothing, velocity or a custom scroll root. The full option lists belong to the underlying `fizban` (scroll) and `kuliso` (pointer) controllers; see the [`@wix/motion` package](https://github.com/wix/interact/tree/master/packages/motion) for how Interact drives them. + +> **Warning:** `Interact.setup()` **replaces**, it does not merge. Every key you pass overwrites the previous value for that key outright — a second call with `{ viewEnter: { inset: '10%' } }` discards a `threshold` set by the first call, and a second `scrollOptionsGetter` replaces the earlier getter. Keys you omit are left untouched. Pass the complete set of defaults in a single call. + +Per-effect `params` still win: a `viewEnter` trigger that declares its own `threshold` overrides the global default for that trigger only. + +### Accessible trigger upgrades (`allowA11yTriggers`) + +`hover` and `click` are pointer-only. With `Interact.allowA11yTriggers` left at its default `true`, Interact substitutes the accessible variant when it binds those triggers: + +| Declared trigger | Bound as | Listens to | +| :--------------- | :--------- | :--------------------------------------------------------- | +| `hover` | `interest` | `mouseenter` / `focusin` in, `mouseleave` / `focusout` out | +| `click` | `activate` | `click`, plus `keydown` filtered to `Enter` and `Space` | + +The `interest` upgrade also sets `tabIndex = 0` on the source element so it can receive keyboard focus, and it ignores focus moves that stay inside the source. The `activate` upgrade does not add focusability — the source still needs to be natively focusable or carry its own `tabindex`. + +Set `allowA11yTriggers: false` when the upgrade fights your own markup: for example when the source already contains a real `