diff --git a/packages/interact/dev/experience-spec.md b/packages/interact/dev/experience-spec.md new file mode 100644 index 00000000..ac1009c4 --- /dev/null +++ b/packages/interact/dev/experience-spec.md @@ -0,0 +1,2092 @@ +# Motion Experiences + +A data model for defining, persisting, and editing web motion compositions built on `@wix/interact`. + +## Problem Statement + +Today, a rich motion composition requires manually wiring together CSS styling, an `InteractConfig` for effects and triggers, and registering the right presets. There is no single portable artifact that captures all of these together, and no standard way to expose user-facing editing controls over the result. + +We need a **declarative, JSON-serializable data structure** that: + +1. Fully describes a motion experience (element mapping, style, animation, interaction). +2. Can be **saved, loaded, and edited** by end users through a set of high-level controls. +3. Can be **reliably generated by LLMs** without producing invalid or unsafe output. + +## Goals + +| # | Goal | Rationale | +| --- | ------------------------- | ---------------------------------------------------------------------------------------------- | +| G1 | Selector-driven | Elements are referenced by CSS selectors, not created. The experience applies to existing DOM. | +| G2 | Serializable | No functions, class instances, or DOM references — pure data. | +| G3 | LLM-safe | Constrained vocabulary, closed enums, clear defaults, no code generation. | +| G4 | Editable via controls | High-level knobs that map to multiple low-level properties. | +| G5 | Built on Interact | The animation layer uses `InteractConfig` types directly. | +| G6 | Versionable | An explicit schema version enables forward-compatible evolution. | +| G7 | Conditionally disableable | The entire experience can be disabled under specific media conditions. | + +## Non-Goals + +- Creating DOM elements (the experience assumes markup already exists and selects into it). +- Full page layout or content authoring (this is not a page builder). +- Runtime rendering implementation (that lives in a separate renderer package). +- Defining new animation presets (experiences compose existing presets). + +--- + +## Data Model + +### Top-Level: `Experience` + +```ts +type Experience = { + $schema: 'interact-experience/1.0'; + id: string; + name: string; + description?: string; + + elements: Record; + styles?: StyleRule[]; + interact: ExperienceInteractConfig; + controls: Control[]; + disableWhen?: MediaCondition[]; + + meta?: ExperienceMeta; +}; +``` + +| Field | Purpose | +| ------------- | --------------------------------------------------------------------------------------------------------------------- | +| `$schema` | Schema version identifier for forward compatibility. | +| `id` | Globally unique identifier for this experience. | +| `name` | Human-readable name (shown in galleries / pickers). | +| `description` | Optional prose description of what the experience does. | +| `elements` | Map of logical names to selectors and base styles (see [Elements](#elements)). | +| `styles` | Optional additional CSS rules — responsive overrides, pseudo-elements, complex selectors (see [Styles](#styles)). | +| `interact` | A serializable `InteractConfig` subset (see [Interact Config](#interact-config)). | +| `controls` | User-facing editing controls (see [Controls](#controls-system)). | +| `disableWhen` | Media conditions under which the entire experience is disabled (see [Experience Conditions](#experience-conditions)). | +| `meta` | Optional metadata for categorization and discovery. | + +--- + +### Elements + +An experience does not create DOM elements. It **selects** existing elements by CSS selector, applies styles to them, and wires them up to interactions via their key. + +```ts +type ElementEntry = { + selector: string; + styles?: Record; +}; +``` + +| Field | Purpose | +| ---------- | --------------------------------------------------- | +| `selector` | CSS selector used to find the element in the DOM. | +| `styles` | Base CSS properties applied to the matched element. | + +The keys in the `elements` map serve as the **logical names** for the experience's elements. These keys are used: + +1. As the `key` in `interact.interactions` to wire up triggers and effects. +2. As `targetId` in control bindings that target an element's styles. + +At initialization, the renderer finds each element by its selector and adds a `data-interact-key` attribute set to the element's key, enabling Interact to reference it. + +#### Example + +```json +{ + "elements": { + "card": { + "selector": ".product-card", + "styles": { + "border-radius": "12px", + "overflow": "hidden" + } + }, + "card-image": { + "selector": ".product-card .image", + "styles": { + "object-fit": "cover", + "width": "100%" + } + } + } +} +``` + +--- + +### Styles + +The optional `styles` array holds CSS rules that go beyond per-element base styles: responsive overrides via media queries, pseudo-element rules, or complex multi-selector rules. + +```ts +type StyleRule = { + selector: string; + properties: Record; + mediaQuery?: string; +}; +``` + +| Field | Purpose | +| ------------ | ---------------------------------------------------------------------- | +| `selector` | A CSS selector. Scoped to the experience's root at render time. | +| `properties` | CSS property-value pairs (e.g. `{ "border-radius": "12px" }`). | +| `mediaQuery` | Optional media query that wraps this rule (e.g. `(min-width: 768px)`). | + +Per-element styles in `elements[key].styles` are applied first. Rules in the `styles` array are applied after and can override them, following standard CSS specificity. + +CSS custom properties (`--experience-*`) can be used within style rules and referenced by Interact effects, providing a bridge between visual styling and motion behavior. + +--- + +### Interact Config + +The experience embeds a **serializable subset** of `InteractConfig`. The key restriction is that `customEffect` (which requires a function reference) is **not allowed**. Only `namedEffect`, `keyframeEffect`, and `transition`/`transitionProperties` (state effects) are permitted as animation payloads. Similarly, `offsetEasing` as a function is not allowed in sequences — only string-based easing names. + +```ts +type ExperienceInteractConfig = { + effects: Record; // REQUIRED; may be {} if unused + sequences?: Record; + conditions?: Record; + interactions: ExperienceInteraction[]; +}; +``` + +| Field | Purpose | +| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `effects` | Map of effect-id → effect definition. Always present. Entries are referenced from interactions and sequences via `effectId`. May be empty if unused. | +| `sequences` | Map of sequence-id → sequence definition. Used when multiple effects should fire in a coordinated stagger. Referenced from interactions via `sequenceId`. | +| `conditions` | Map of condition-id → condition. Referenced from interactions, effects, and sequences via `conditions: string[]`. All listed conditions must pass to apply. | +| `interactions` | Array of interactions binding triggers on source elements to one or more effects/sequences. | + +#### Effect Base + +All serializable effects share a common base that refines the target element and gates the effect: + +```ts +type EffectBase = { + key?: string; // target element key; omit to target the source + effectId?: string; // effect identifier (required when used as a ref) + selector?: string; // refine target within the keyed element + listContainer?: string; // CSS selector for list container + listItemSelector?: string; // filter which children of listContainer are targeted + conditions?: string[]; // condition-ids; all must pass +}; + +type SerializableEffectRef = EffectBase & { effectId: string }; +``` + +#### Serializable Effects + +Every inline effect is a `EffectBase` combined with **exactly one** of three effect shapes: time-based, scroll/pointer-driven (scrub), or state (CSS transition toggle). + +```ts +type SerializableEffectSource = + | { namedEffect: NamedEffect } + | { keyframeEffect: { name: string; keyframes: Keyframe[] } }; + +type SerializableTimeEffect = SerializableEffectSource & { + duration: number; + easing?: string; + iterations?: number; + alternate?: boolean; + reversed?: boolean; + delay?: number; + fill?: 'none' | 'forwards' | 'backwards' | 'both'; + composite?: 'replace' | 'add' | 'accumulate'; + triggerType?: 'once' | 'repeat' | 'alternate' | 'state'; +}; + +type SerializableScrubEffect = SerializableEffectSource & { + rangeStart?: RangeOffset; + rangeEnd?: RangeOffset; + easing?: string; + iterations?: number; + alternate?: boolean; + reversed?: boolean; + fill?: 'none' | 'forwards' | 'backwards' | 'both'; + composite?: 'replace' | 'add' | 'accumulate'; + centeredToTarget?: boolean; + transitionDuration?: number; + transitionDelay?: number; + transitionEasing?: 'linear' | 'hardBackOut' | 'easeOut' | 'elastic' | 'bounce'; +}; + +type SerializableStateEffect = { + stateAction?: 'add' | 'remove' | 'toggle' | 'clear'; + transition?: { + duration?: number; + delay?: number; + easing?: string; + styleProperties: { name: string; value: string }[]; + }; + transitionProperties?: Array<{ + name: string; + value: string; + duration?: number; + delay?: number; + easing?: string; + }>; +}; + +type SerializableEffect = EffectBase & + (SerializableTimeEffect | SerializableScrubEffect | SerializableStateEffect); +``` + +| Effect shape | Trigger compatibility | Payload fields | +| ----------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------- | +| `SerializableTimeEffect` | `hover`, `click`, `activate`, `interest`, `viewEnter`, `pageVisible`, `animationEnd` | `duration` required, `triggerType` controls playback on repeated triggers | +| `SerializableScrubEffect` | `viewProgress`, `pointerMove` | `rangeStart`/`rangeEnd` for `viewProgress`; `transition*` for pointer smoothing | +| `SerializableStateEffect` | `hover`, `click`, `activate`, `interest` | `transition` (shared timing) or `transitionProperties` (per-property timing) | + +**`triggerType`** (on `SerializableTimeEffect`) controls how repeated trigger events replay the effect: + +| Value | hover / interest | click / activate | viewEnter / pageVisible | +| ------------- | --------------------------------- | ---------------------------------- | ---------------------------------------------- | +| `'once'` | Play once on first enter | Play once on first click | Play once when source first enters viewport | +| `'repeat'` | Restart on each enter | Restart on each click | Restart every time source enters viewport | +| `'alternate'` | Play on enter, reverse on leave (default for hover/click) | Alternate play/reverse per click | Play on enter, reverse on leave | +| `'state'` | Resume on enter, pause on leave | Toggle play/pause per click | Resume on enter, pause on leave | + +**`stateAction`** (on `SerializableStateEffect`) controls how the style state is applied: + +| Value | Behavior | +| ----------- | --------------------------------------------------------------------------------------- | +| `'toggle'` | (default) Apply on enter/click, remove on leave/next click. | +| `'add'` | Apply on enter/click. Does not remove automatically. | +| `'remove'` | Remove a previously applied state on enter/click. Pair with a matching `add` effectId. | +| `'clear'` | Clear all previously applied state styles on the target. | + +**`composite`** mirrors CSS `animation-composition`: how this effect combines with others on the same property (primarily transforms and filters): `'replace'` (default), `'add'` (concatenate functions), `'accumulate'` (sum matching function arguments). + +#### Range Offset + +`RangeOffset` is imported from `@wix/motion` and defines scroll timeline range boundaries: + +```ts +type RangeOffset = { + name?: 'entry' | 'exit' | 'contain' | 'cover' | 'entry-crossing' | 'exit-crossing'; + offset?: LengthPercentage; +}; + +type LengthPercentage = + | { value: number; unit: 'px' | 'em' | 'rem' | 'vh' | 'vw' | 'vmin' | 'vmax' } + | { value: number; unit: 'percentage' }; +``` + +| Range name | Meaning | +| ---------------- | ---------------------------------------------------------------------------------------- | +| `entry` | While the element is entering the viewport. | +| `exit` | While the element is exiting the viewport. | +| `contain` | While the element is fully contained in the viewport (typical for `position: sticky`). | +| `cover` | Full range from first pixel entering to last pixel leaving. | +| `entry-crossing` | From the leading edge entering to the leading edge reaching the opposite viewport side. | +| `exit-crossing` | From the trailing edge reaching the leading viewport side to the trailing edge leaving. | + +#### Sequences + +Sequences allow grouping effects into ordered timelines with staggered delays. They are declared at the top level for reuse and referenced from interactions. + +```ts +type SerializableSequenceConfig = { + effects: (SerializableEffect | SerializableEffectRef)[]; // REQUIRED + delay?: number; + offset?: number; + offsetEasing?: string; + triggerType?: 'once' | 'repeat' | 'alternate' | 'state'; + sequenceId?: string; + conditions?: string[]; +}; + +type SerializableSequenceConfigRef = { + sequenceId: string; // REQUIRED — must match a key in interact.sequences + delay?: number; + offset?: number; + offsetEasing?: string; + triggerType?: 'once' | 'repeat' | 'alternate' | 'state'; + conditions?: string[]; +}; +``` + +| Field | Purpose | +| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `effects` | Ordered list of effects in the sequence. Each effect targets its own element via `key` (or `listContainer` / `listItemSelector`). | +| `delay` | Fixed millisecond delay before the sequence starts playing. Defaults to `0`. | +| `offset` | Millisecond offset between consecutive children's animation starts (stagger interval). Defaults to `0`. | +| `offsetEasing` | Named easing string (e.g. `"ease-out"`, `"quadIn"`) shaping stagger distribution. Function form is not allowed in serializable configs. Defaults to `'linear'`. | +| `triggerType` | Playback behavior for repeated trigger events. Set on the sequence (not on individual child effects). Same semantics as `triggerType` on a `TimeEffect`. | +| `sequenceId` | Identifier for a reusable sequence stored in `interact.sequences`, referenced via `SerializableSequenceConfigRef`. | +| `conditions` | Condition-ids that must all pass for the sequence to be active. | + +Sequences can be defined inline on an interaction's `sequences` array or declared once in `interact.sequences` and referenced by `sequenceId` elsewhere. + +#### Interactions + +Each interaction binds a trigger on a source element to one or more effects or sequences. The `key` field references an element key from the `elements` map, and the same element is used as both source and target by default unless an effect overrides the target via its own `key`. + +```ts +type ExperienceInteraction = { + id?: string; // experience-layer identifier, used as `targetId` for 'interaction' bindings + key: string; // REQUIRED — matches a key in elements + trigger: TriggerType; // REQUIRED + params?: TriggerParams; // trigger-specific parameters (see Trigger Params) + selector?: string; // refine the source element within the keyed element + listContainer?: string; // attach the trigger to items in a list + listItemSelector?: string; // filter which children of listContainer act as sources + conditions?: string[]; // condition-ids; all must pass to activate + effects?: (SerializableEffect | SerializableEffectRef)[]; + sequences?: (SerializableSequenceConfig | SerializableSequenceConfigRef)[]; +}; +``` + +| Field | Purpose | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | Optional experience-layer identifier. Not part of `@wix/interact`'s own `Interaction` type — used only to resolve `target: 'interaction'` control bindings. | +| `key` | Required. Element key in `elements` whose matched element is the trigger source (and default target). | +| `trigger` | Required. Trigger type (see [Trigger Types](#trigger-types)). | +| `params` | Trigger-specific parameters. Shape depends on `trigger` (see [Trigger Params](#trigger-params)). | +| `selector` | CSS selector refining the source element within the keyed element. | +| `listContainer` | CSS selector for a list container whose children become the trigger sources. | +| `listItemSelector` | CSS selector filtering which immediate children of `listContainer` participate. | +| `conditions` | Condition-ids gating the entire interaction. All listed conditions must pass. | +| `effects` | Effects to apply when the trigger activates. Each effect may be inline or an `effectId`-based reference. | +| `sequences` | Sequences to apply when the trigger activates. Each sequence may be inline or a `sequenceId`-based reference. | + +An interaction MUST provide at least one of `effects` or `sequences`. + +#### Trigger Types + +The full set of trigger types available in Interact: + +```ts +type TriggerType = + | 'hover' + | 'click' + | 'interest' + | 'activate' + | 'viewEnter' + | 'viewProgress' + | 'pointerMove' + | 'animationEnd'; +``` + +| Trigger | Description | +| -------------- | ------------------------------------------------------------------------------------------------------------------ | +| `hover` | Fires on mouse enter/leave. | +| `click` | Fires on element click. | +| `interest` | Accessible hover — fires on hover **or** keyboard focus. Prefer over `hover` for keyboard-reachable targets. | +| `activate` | Accessible click — fires on click **or** keyboard activation (Enter/Space). Prefer over `click` for keyboard users. | +| `viewEnter` | Fires when the source element enters the viewport (configurable via `threshold`, `inset`). | +| `viewProgress` | Scrub-driven: maps scroll progress through the element's ViewTimeline to the effect's progress. | +| `pointerMove` | Scrub-driven: maps pointer position over a hit area to the effect's progress. | +| `animationEnd` | Chains when another effect (referenced by `effectId` in `params`) finishes. | + +> **Accessibility:** `interest` and `activate` require `Interact.allowA11yTriggers = true` (or `allowA11yTriggers: true` via `Interact.setup`) to be enabled globally. + +#### Trigger Params + +Each trigger type accepts a specific `params` shape — or no params at all. **Playback behavior (once/repeat/alternate/state)** is NOT a trigger param — it lives on each effect as `triggerType` (or on a sequence as `triggerType`). + +```ts +type TriggerParams = ViewEnterParams | PointerMoveParams | AnimationEndParams; + +type ViewEnterParams = { + threshold?: number; // 0–1, IntersectionObserver threshold + inset?: string; // CSS-style inset (rootMargin), e.g. '-100px' or '-50px 0px' + useSafeViewEnter?: boolean; // opt-in to the safe viewEnter implementation +}; + +type PointerMoveParams = { + hitArea?: 'root' | 'self'; // where pointer motion is tracked + axis?: 'x' | 'y'; // single-axis keyframeEffect mapping; default 'y' when keyframeEffect is used +}; + +type AnimationEndParams = { + effectId: string; // REQUIRED — the effect to chain from +}; +``` + +| Trigger | Params shape | Notes | +| ------------------------------------------- | ---------------------------- | -------------------------------------------------------------------------------------------------------- | +| `hover` / `click` / `interest` / `activate` | _none_ | No params. Set `triggerType` on each `TimeEffect` or `stateAction` on each `StateEffect`. | +| `viewEnter` | `ViewEnterParams` | Set `triggerType` on each effect (or on a sequence) — not on params. | +| `viewProgress` | _none_ | Configure scroll range via `rangeStart` / `rangeEnd` on each `ScrubEffect`. | +| `pointerMove` | `PointerMoveParams` | `axis` only applies to `keyframeEffect`. For 2D effects prefer a `namedEffect` from the `Mouse` preset set. | +| `animationEnd` | `AnimationEndParams` | `effectId` identifies the preceding effect whose completion chains into this trigger. | + +#### Conditions + +Conditions control when interactions, effects, or sequences are active. They are declared in `interact.conditions` and referenced by key from `conditions: string[]` on any of those levels. All listed conditions must pass for the gated item to activate. + +```ts +type Condition = { + type: 'media' | 'container' | 'selector'; + predicate?: string; +}; +``` + +| Type | `predicate` example | Description | +| ----------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------- | +| `media` | `'(hover: hover)'`, `'(min-width: 768px)'` | CSS media query (omit the `@media` prefix). Matched via `window.matchMedia()`. | +| `container` | `'(min-width: 480px)'` | CSS container query evaluated against the nearest container context of the element. | +| `selector` | `':nth-of-type(odd)'`, `'&.featured'`, `'body.dark &'` | CSS selector evaluated against the element. The `&` token is substituted with the base element selector (nesting-style). | + +Scoping: on an **interaction**, conditions gate the entire trigger — no effects or sequences activate when a condition fails. On an **effect** or **sequence**, conditions gate only that specific item; the remaining items in the interaction still evaluate independently. + +#### Named Effect Types + +All existing named effect types from `@wix/motion-presets` are available as `namedEffect: { type: '[PRESET_NAME]', ...options }`: + +| Category | Types | +| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Entrance** | `FadeIn`, `GlideIn`, `SlideIn`, `FloatIn`, `RevealIn`, `ExpandIn`, `BlurIn`, `FlipIn`, `ArcIn`, `ShuttersIn`, `CurveIn`, `DropIn`, `FoldIn`, `ShapeIn`, `TiltIn`, `WinkIn`, `SpinIn`, `TurnIn`, `BounceIn` | +| **Ongoing** | `Pulse`, `Spin`, `Breathe`, `Bounce`, `Wiggle`, `Flash`, `Flip`, `Fold`, `Jello`, `Poke`, `Rubber`, `Swing`, `Cross` | +| **Scroll** | `FadeScroll`, `RevealScroll`, `ParallaxScroll`, `MoveScroll`, `SlideScroll`, `GrowScroll`, `ShrinkScroll`, `TiltScroll`, `PanScroll`, `BlurScroll`, `FlipScroll`, `SpinScroll`, `ArcScroll`, `ShapeScroll`, `ShuttersScroll`, `SkewPanScroll`, `Spin3dScroll`, `StretchScroll`, `TurnScroll` | +| **Mouse** | `TrackMouse`, `Tilt3DMouse`, `Track3DMouse`, `SwivelMouse`, `AiryMouse`, `ScaleMouse`, `BlurMouse`, `SkewMouse`, `BlobMouse` | +| **Background Scroll** | `BgParallax`, `BgZoom`, `BgPan`, `BgFade`, `BgRotate`, `BgSkew`, `BgCloseUp`, `BgFadeBack`, `BgFake3D`, `BgPullBack`, `BgReveal`, `ImageParallax` | + +Each named effect type has its own set of parameters (direction, speed, intensity, etc.) that can be driven by controls. + +> **Scroll preset `range`:** Any `*Scroll` preset used with `viewProgress` MUST include a `range` option in the `namedEffect`: `'in'` (ends at idle state), `'out'` (starts from idle state), or `'continuous'` (passes through idle). Prefer `'continuous'` when unsure. + +> **Note:** `CustomMouse` also exists in `@wix/motion-presets` but is excluded from experience configs because its behavior is driven by runtime `customEffect` functions, which are not serializable. + +--- + +## Experience Conditions + +An experience can declare media conditions under which it should be **entirely disabled** — no styles applied, no interactions registered. This is distinct from per-interaction conditions in the `interact` config, which selectively enable or disable individual effects. + +```ts +type MediaCondition = { + mediaQuery: string; + label?: string; +}; +``` + +| Field | Purpose | +| ------------ | ---------------------------------------------------------------------------- | +| `mediaQuery` | A valid CSS media query string. When it matches, the experience is disabled. | +| `label` | Optional human-readable label for tooling (e.g. `"Mobile devices"`). | + +#### Example + +```json +{ + "disableWhen": [ + { + "mediaQuery": "(max-width: 767px)", + "label": "Mobile devices" + }, + { + "mediaQuery": "(prefers-reduced-motion: reduce)", + "label": "Reduced motion preference" + } + ] +} +``` + +When **any** condition in `disableWhen` matches, the renderer must: + +1. Skip injecting the experience's styles. +2. Skip initializing Interact and registering interactions. +3. If the experience was already active, tear it down and remove applied styles. + +This is evaluated via `window.matchMedia()` and should react to changes (e.g. viewport resize, system preference toggle). + +--- + +## Controls System + +Controls are the primary mechanism for end-user editing. Each control exposes a single, understandable knob that can drive **multiple** low-level properties simultaneously. + +### Design Principles + +1. **High-level over low-level.** Prefer a single "Intensity" control that adjusts scale, distance, and duration together over three separate controls. +2. **Bounded.** Every control has a type, constraints, and a default. There is no free-form input. +3. **Deterministic.** The mapping from control value to experience properties is a pure function — same input, same output. +4. **Composable.** Multiple controls can bind to the same property. Last-write-wins based on control order. + +### Control Type + +```ts +type Control = { + id: string; + label: string; + description?: string; + group?: string; + type: ControlType; + defaultValue: ControlValue; + constraints?: ControlConstraints; + bindings: ControlBinding[]; +}; + +type ControlType = + | 'range' // numeric slider + | 'select' // dropdown / segmented picker + | 'color' // color picker + | 'toggle' // boolean switch + | 'text'; // short text input + +type ControlValue = number | string | boolean; + +type ControlConstraints = { + min?: number; // for 'range' + max?: number; // for 'range' + step?: number; // for 'range' + unit?: string; // display unit label ('px', 'ms', '%', 'x', '°') + options?: ControlOption[]; // for 'select' +}; + +type ControlOption = { + value: string | number; + label: string; +}; +``` + +### Control Bindings + +A binding maps the control's value to a property within the experience. Each binding specifies a **target domain**, a **target identifier**, an optional **property path**, and an optional **transform**. + +```ts +type ControlBinding = { + target: BindingTarget; + targetId: string; + property?: string; + transform?: ValueTransform; +}; + +type BindingTarget = + | 'effect' // targets an entry in interact.effects by effect key + | 'style' // targets a StyleRule in the styles array by selector + | 'element' // targets an ElementEntry in the elements map by key + | 'interaction' // targets an ExperienceInteraction by id + | 'variable'; // sets a CSS custom property on the experience scope +``` + +| Field | Description | +| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `target` | Which part of the experience this binding writes to. | +| `targetId` | The key/id/selector that identifies the specific target. For `effect`, this is the effect key in the `effects` map. For `style`, this is the CSS selector string matching a rule in `styles`. For `element`, this is the element's key in the `elements` map. For `interaction`, this is the interaction's `id`. For `variable`, this is the CSS custom property name (e.g. `--exp-spacing`). | +| `property` | Dot-path to the property within the target. For `element` bindings, paths are relative to the element's `styles` (e.g. `styles.border-radius`). For `effect` bindings, paths address the effect object (e.g. `duration`, `namedEffect.speed`). For `style` bindings, paths address the style rule (e.g. `properties.min-height`). Not used for `variable` bindings. | +| `transform` | How to derive the final property value from the control value. Defaults to `{ type: 'direct' }`. | + +### Value Transforms + +Transforms are deliberately limited to a small set of pure, serializable operations. No arbitrary expressions or code. + +```ts +type ValueTransform = + | { type: 'direct' } + | { type: 'linear'; factor: number; offset?: number } + | { type: 'inverse'; numerator: number } + | { type: 'map'; entries: Record } + | { type: 'template'; template: string }; +``` + +| Transform | Formula | Example Use | +| ---------- | ---------------------------------------------- | ------------------------------------- | +| `direct` | `output = value` | Toggle → `reversed` boolean | +| `linear` | `output = factor × value + offset` | Slider 1–10 → duration 100–1000 | +| `inverse` | `output = numerator / value` | Speed multiplier → duration | +| `map` | `output = entries[value]` | "slow"/"medium"/"fast" → 1200/800/400 | +| `template` | `output = template.replace('${value}', value)` | Slider 12 → `"12px"` | + +### Applying Controls + +The resolution algorithm for rendering an experience with user-provided control values: + +``` +for each control in experience.controls: + value = userValues[control.id] ?? control.defaultValue + for each binding in control.bindings: + target = resolve(experience, binding.target, binding.targetId) + finalValue = applyTransform(value, binding.transform) + set(target, binding.property, finalValue) +``` + +Controls are applied in declaration order. If two controls bind to the same property, the later control wins. + +### Multi-Value Binding Patterns + +A single control should ideally drive a **cohesive aspect** of the experience rather than a single isolated property. There are two primary patterns for achieving this. + +#### Pattern 1: Coordinated Multi-Binding + +A single control carries multiple `bindings`, each targeting a different property. When the control value changes, all bindings resolve simultaneously. This is most natural with `select` controls and `map` transforms, where each option maps to a curated set of values across styles and effects. + +```json +{ + "id": "mood", + "label": "Mood", + "type": "select", + "defaultValue": "cinematic", + "constraints": { + "options": [ + { "value": "cinematic", "label": "Cinematic" }, + { "value": "minimal", "label": "Minimal" }, + { "value": "energetic", "label": "Energetic" } + ] + }, + "bindings": [ + { + "target": "effect", + "targetId": "bg-zoom", + "property": "namedEffect.scale", + "transform": { + "type": "map", + "entries": { "cinematic": 1.4, "minimal": 1.05, "energetic": 1.6 } + } + }, + { + "target": "effect", + "targetId": "reveal-effect", + "property": "easing", + "transform": { + "type": "map", + "entries": { + "cinematic": "cubic-bezier(0.16, 1, 0.3, 1)", + "minimal": "ease-in-out", + "energetic": "cubic-bezier(0.34, 1.56, 0.64, 1)" + } + } + }, + { + "target": "element", + "targetId": "overlay", + "property": "styles.background", + "transform": { + "type": "map", + "entries": { + "cinematic": "rgba(0, 0, 0, 0.5)", + "minimal": "rgba(0, 0, 0, 0.1)", + "energetic": "rgba(0, 0, 0, 0.3)" + } + } + }, + { + "target": "element", + "targetId": "heading", + "property": "styles.letter-spacing", + "transform": { + "type": "map", + "entries": { "cinematic": "0.1em", "minimal": "0", "energetic": "-0.02em" } + } + } + ] +} +``` + +One `select` change writes four properties across two effects and two elements. Each option is a hand-curated combination that forms a coherent visual language. + +This pattern works equally well with `range` and `linear` transforms. A single "Intensity" slider can drive scale, blur radius, and shadow depth simultaneously: + +```json +{ + "id": "intensity", + "label": "Intensity", + "type": "range", + "defaultValue": 5, + "constraints": { "min": 1, "max": 10, "step": 1 }, + "bindings": [ + { + "target": "effect", + "targetId": "hover-zoom", + "property": "namedEffect.scale", + "transform": { "type": "linear", "factor": 0.02, "offset": 0.9 } + }, + { + "target": "effect", + "targetId": "entrance", + "property": "duration", + "transform": { "type": "linear", "factor": -80, "offset": 1200 } + }, + { + "target": "element", + "targetId": "card", + "property": "styles.box-shadow", + "transform": { + "type": "template", + "template": "0 ${value}px calc(${value}px * 3) rgba(0, 0, 0, 0.08)" + } + } + ] +} +``` + +#### Pattern 2: CSS Custom Property Cascade + +Instead of binding to each CSS property individually, set a single CSS custom property via a `variable` binding. Style rules then reference that property through `var()` and `calc()`, allowing **unlimited CSS properties to react to one control** without additional bindings. + +The `variable` binding target sets a CSS custom property on the experience scope. The `targetId` is the property name, and `property` is not needed: + +```json +{ + "id": "spacing", + "label": "Spacing", + "type": "range", + "defaultValue": 24, + "constraints": { "min": 8, "max": 48, "step": 4, "unit": "px" }, + "bindings": [ + { + "target": "variable", + "targetId": "--exp-spacing", + "transform": { "type": "direct" } + } + ] +} +``` + +Element styles and style rules reference the variable. The renderer sets the initial value from `control.defaultValue` (after applying `transform`), so styles always have a resolved value: + +```json +{ + "elements": { + "gallery": { + "selector": ".gallery-container", + "styles": { + "gap": "calc(var(--exp-spacing) * 1px)", + "padding": "calc(var(--exp-spacing) * 1.5px) calc(var(--exp-spacing) * 1px)" + } + }, + "item-caption": { + "selector": ".gallery-item .caption", + "styles": { + "padding": "calc(var(--exp-spacing) * 0.67px) calc(var(--exp-spacing) * 0.83px)" + } + } + } +} +``` + +When the user moves the slider to `32`, the renderer sets `--exp-spacing: 32` on the experience scope, and **all three CSS properties** (`gap`, container `padding`, caption `padding`) update simultaneously through CSS's own cascade — no additional JavaScript or bindings needed. + +This pattern is powerful for properties that scale proportionally. Common use cases: + +| Variable | Drives | +| ----------------------- | ----------------------------------------------------------- | +| `--exp-spacing` | `gap`, `padding`, `margin` — proportional layout spacing | +| `--exp-radius` | `border-radius` on multiple elements at different scales | +| `--exp-hue` | `hsl()` color values across backgrounds, borders, text | +| `--exp-parallax-factor` | `translateY` offset in multiple keyframe `var()` references | + +The two patterns can be **combined**. A `select` control can set a CSS custom property via a `variable` binding while also setting effect properties via `effect` bindings: + +```json +{ + "id": "density", + "label": "Density", + "type": "select", + "defaultValue": "comfortable", + "constraints": { + "options": [ + { "value": "compact", "label": "Compact" }, + { "value": "comfortable", "label": "Comfortable" }, + { "value": "spacious", "label": "Spacious" } + ] + }, + "bindings": [ + { + "target": "variable", + "targetId": "--exp-spacing", + "transform": { + "type": "map", + "entries": { "compact": 12, "comfortable": 24, "spacious": 40 } + } + }, + { + "target": "effect", + "targetId": "item-entrance", + "property": "duration", + "transform": { + "type": "map", + "entries": { "compact": 300, "comfortable": 500, "spacious": 800 } + } + } + ] +} +``` + +--- + +## Metadata + +```ts +type ExperienceMeta = { + category?: string; + tags?: string[]; + previewUrl?: string; + author?: string; + createdAt?: string; +}; +``` + +Metadata is informational only and does not affect rendering. It supports discovery, filtering, and attribution in experience galleries. + +--- + +## LLM Generation Contract + +This section defines the rules that LLMs must follow when generating experiences. The goal is to produce valid, safe, and visually coherent output on the first attempt. + +### Structural Rules + +1. **Always include `$schema`** set to `'interact-experience/1.0'`. +2. **Generate a unique `id`** using a descriptive kebab-case slug (e.g. `parallax-hero-section`). +3. **Every element key must be unique** within the `elements` map. +4. **No `customEffect`** — only `namedEffect` or `keyframeEffect` as effect sources. +5. **Every `key` used in an interaction must exist** in the `elements` map. +6. **Every `effectId` referenced in an interaction must exist** in the `interact.effects` map. +7. **Every `sequenceId` referenced in an interaction must exist** in the `interact.sequences` map. +8. **Every condition referenced must be defined** in the `interact.conditions` map. +9. **No function-form `offsetEasing`** in sequences — only string-based easing names. +10. **Selectors must be specific** — avoid bare tag selectors like `div` or `span`. Use class-based or structural selectors. + +### Naming Conventions + +| Item | Convention | Example | +| -------------- | ---------- | ----------------------------------- | +| Experience ID | kebab-case | `floating-card-gallery` | +| Element keys | kebab-case | `hero-title`, `bg-image` | +| Effect keys | kebab-case | `entrance-fade`, `hover-zoom` | +| Sequence keys | kebab-case | `stagger-entrance`, `card-sequence` | +| Control IDs | kebab-case | `entrance-speed`, `bg-intensity` | +| Condition keys | kebab-case | `desktop`, `reduced-motion` | + +### Effect Selection Guidelines + +| Intent | Recommended Approach | +| ------------------------ | ---------------------------------------------------------------------------------------------- | +| Simple opacity entrance | `namedEffect: { type: 'FadeIn' }` with `triggerType: 'once'` | +| Directional entrance | `namedEffect: { type: 'SlideIn', direction: '...' }` or `GlideIn` with `triggerType: 'once'` | +| Scroll-driven parallax | `namedEffect: { type: 'ParallaxScroll', range: 'continuous', ... }` | +| Hover micro-interaction | `keyframeEffect` with `transform: scale(...)`, `fill: 'both'`, `triggerType: 'alternate'` | +| Hover style toggle | `transition` or `transitionProperties` with `stateAction: 'toggle'` | +| Background scroll effect | `namedEffect: { type: 'BgParallax', range: 'continuous', speed: ... }` | +| Custom keyframe motion | `keyframeEffect: { name: '...', keyframes: [...] }` | +| 2D pointer-tracking | `namedEffect: { type: 'TrackMouse', ... }` (preferred over `keyframeEffect` for 2D) | +| 1D pointer-tracking | `keyframeEffect` with `params.axis: 'x' | 'y'` on the `pointerMove` interaction | + +Prefer `namedEffect` over `keyframeEffect` when a suitable preset exists. Named effects are more concise, better tested, and expose semantically meaningful parameters. + +### Control Design Guidelines + +1. **Aim for 3–7 controls per experience.** Fewer feels limiting; more overwhelms end users. +2. **Group related controls** using the `group` field (e.g. `"Animation"`, `"Style"`, `"Layout"`). +3. **Prefer `select` and `range`** over `text`. Bounded inputs are safer and easier to use. +4. **Use `map` transforms for semantic choices.** E.g. a "Speed" select with "Slow"/"Medium"/"Fast" is better than a raw millisecond slider. +5. **Multi-bind where possible.** A single "Intensity" control that adjusts `distance`, `scale`, and `blur` together is more useful than three separate controls. +6. **Use `variable` bindings for proportional CSS properties.** When multiple CSS properties should scale together (spacing, radius, color), bind to a single CSS custom property and use `var()` / `calc()` in styles. This is more maintainable and performant than N separate bindings. +7. **Combine patterns freely.** A single control can mix `variable`, `effect`, and `element` bindings to coordinate CSS cascade changes with effect parameter changes. + +### Generation Order + +LLMs should generate the experience in this order: + +1. **Elements** — map logical names to selectors and base styles. +2. **Styles** — add responsive overrides and complex selectors. +3. **Interact config** — define effects, sequences, conditions, and wire up interactions using element keys. +4. **Controls** — extract high-level editing knobs from the properties above. +5. **disableWhen** — declare media conditions that disable the experience. +6. **Metadata** — add category and tags last. + +This order ensures that each layer builds on the previous one, reducing the chance of dangling references. + +--- + +## Examples + +### Example 1: Fade-In Card + +A card that fades in when scrolled into view. Demonstrates the minimal element-to-selector mapping. + +```json +{ + "$schema": "interact-experience/1.0", + "id": "fade-in-card", + "name": "Fade-In Card", + "description": "A content card that gracefully fades in when scrolled into view.", + + "elements": { + "card": { + "selector": ".product-card", + "styles": { + "border-radius": "12px", + "overflow": "hidden", + "box-shadow": "0 4px 24px rgba(0, 0, 0, 0.08)" + } + }, + "card-image": { + "selector": ".product-card .card-image", + "styles": { + "width": "100%", + "aspect-ratio": "16 / 9", + "object-fit": "cover", + "display": "block" + } + } + }, + + "interact": { + "effects": { + "card-entrance": { + "namedEffect": { "type": "FadeIn" }, + "duration": 800, + "easing": "ease-out", + "fill": "backwards", + "triggerType": "once" + } + }, + "interactions": [ + { + "key": "card", + "trigger": "viewEnter", + "params": { "threshold": 0.3 }, + "effects": [{ "effectId": "card-entrance" }] + } + ] + }, + + "controls": [ + { + "id": "entrance-speed", + "label": "Entrance Speed", + "group": "Animation", + "type": "select", + "defaultValue": "medium", + "constraints": { + "options": [ + { "value": "slow", "label": "Slow" }, + { "value": "medium", "label": "Medium" }, + { "value": "fast", "label": "Fast" } + ] + }, + "bindings": [ + { + "target": "effect", + "targetId": "card-entrance", + "property": "duration", + "transform": { + "type": "map", + "entries": { "slow": 1200, "medium": 800, "fast": 400 } + } + } + ] + }, + { + "id": "card-radius", + "label": "Corner Roundness", + "group": "Style", + "type": "range", + "defaultValue": 12, + "constraints": { "min": 0, "max": 32, "step": 2, "unit": "px" }, + "bindings": [ + { + "target": "element", + "targetId": "card", + "property": "styles.border-radius", + "transform": { "type": "template", "template": "${value}px" } + } + ] + }, + { + "id": "shadow-intensity", + "label": "Shadow", + "group": "Style", + "type": "select", + "defaultValue": "medium", + "constraints": { + "options": [ + { "value": "none", "label": "None" }, + { "value": "subtle", "label": "Subtle" }, + { "value": "medium", "label": "Medium" }, + { "value": "strong", "label": "Strong" } + ] + }, + "bindings": [ + { + "target": "element", + "targetId": "card", + "property": "styles.box-shadow", + "transform": { + "type": "map", + "entries": { + "none": "none", + "subtle": "0 2px 8px rgba(0, 0, 0, 0.04)", + "medium": "0 4px 24px rgba(0, 0, 0, 0.08)", + "strong": "0 8px 40px rgba(0, 0, 0, 0.16)" + } + } + } + ] + } + ], + + "disableWhen": [{ "mediaQuery": "(prefers-reduced-motion: reduce)", "label": "Reduced motion" }], + + "meta": { + "category": "cards", + "tags": ["entrance", "scroll", "card", "fade"] + } +} +``` + +--- + +### Example 2: Sticky Scroll Reveal + +A section that sticks to the viewport while content layers reveal progressively as the user scrolls through a tall scroll track. Demonstrates `position: sticky`, layered absolute positioning, scroll-driven effects across multiple elements, and a **coordinated multi-binding select** where a single "Mood" control drives overlay opacity, background zoom, and easing curves across five bindings. + +```json +{ + "$schema": "interact-experience/1.0", + "id": "sticky-scroll-reveal", + "name": "Sticky Scroll Reveal", + "description": "Layered content reveals progressively as you scroll through a sticky viewport-locked section.", + + "elements": { + "scroll-track": { + "selector": ".reveal-track", + "styles": { + "position": "relative", + "height": "300vh" + } + }, + "sticky-frame": { + "selector": ".reveal-track .sticky-frame", + "styles": { + "position": "sticky", + "top": "0", + "height": "100vh", + "overflow": "hidden" + } + }, + "overlay": { + "selector": ".reveal-track .sticky-frame::after", + "styles": { + "content": "''", + "position": "absolute", + "inset": "0", + "background": "rgba(0, 0, 0, 0.4)", + "z-index": "0", + "pointer-events": "none" + } + }, + "background": { + "selector": ".reveal-track .bg-media", + "styles": { + "position": "absolute", + "inset": "0", + "z-index": "-1" + } + }, + "bg-image": { + "selector": ".reveal-track .bg-media img", + "styles": { + "width": "100%", + "height": "100%", + "object-fit": "cover" + } + }, + "layer-headline": { + "selector": ".reveal-track .layer-headline", + "styles": { + "position": "absolute", + "inset": "0", + "display": "flex", + "align-items": "center", + "justify-content": "center", + "z-index": "1", + "opacity": "0" + } + }, + "layer-details": { + "selector": ".reveal-track .layer-details", + "styles": { + "position": "absolute", + "inset": "0", + "display": "flex", + "flex-direction": "column", + "align-items": "center", + "justify-content": "flex-end", + "padding-bottom": "10vh", + "z-index": "2", + "opacity": "0" + } + }, + "layer-cta": { + "selector": ".reveal-track .layer-cta", + "styles": { + "position": "absolute", + "inset": "0", + "display": "flex", + "align-items": "center", + "justify-content": "center", + "z-index": "3", + "opacity": "0" + } + } + }, + + "interact": { + "effects": { + "bg-zoom": { + "namedEffect": { + "type": "GrowScroll", + "scale": 1.2, + "direction": "center", + "range": "continuous" + }, + "fill": "both" + }, + "headline-reveal": { + "keyframeEffect": { + "name": "headline-reveal", + "keyframes": [ + { "opacity": "0", "transform": "translateY(40px)" }, + { "opacity": "1", "transform": "translateY(0)" }, + { "opacity": "1", "transform": "translateY(0)" }, + { "opacity": "0", "transform": "translateY(-20px)" } + ] + }, + "fill": "both", + "rangeStart": { "name": "cover", "offset": { "value": 0, "unit": "percentage" } }, + "rangeEnd": { "name": "cover", "offset": { "value": 50, "unit": "percentage" } } + }, + "details-reveal": { + "keyframeEffect": { + "name": "details-reveal", + "keyframes": [ + { "opacity": "0", "transform": "translateY(60px)" }, + { "opacity": "1", "transform": "translateY(0)" }, + { "opacity": "1", "transform": "translateY(0)" }, + { "opacity": "0", "transform": "translateY(-20px)" } + ] + }, + "fill": "both", + "rangeStart": { "name": "cover", "offset": { "value": 20, "unit": "percentage" } }, + "rangeEnd": { "name": "cover", "offset": { "value": 70, "unit": "percentage" } } + }, + "cta-reveal": { + "keyframeEffect": { + "name": "cta-reveal", + "keyframes": [ + { "opacity": "0", "transform": "scale(0.9)" }, + { "opacity": "1", "transform": "scale(1)" } + ] + }, + "fill": "both", + "rangeStart": { "name": "cover", "offset": { "value": 60, "unit": "percentage" } }, + "rangeEnd": { "name": "cover", "offset": { "value": 90, "unit": "percentage" } } + } + }, + "interactions": [ + { + "key": "scroll-track", + "trigger": "viewProgress", + "effects": [{ "key": "background", "effectId": "bg-zoom" }] + }, + { + "key": "scroll-track", + "trigger": "viewProgress", + "effects": [{ "key": "layer-headline", "effectId": "headline-reveal" }] + }, + { + "key": "scroll-track", + "trigger": "viewProgress", + "effects": [{ "key": "layer-details", "effectId": "details-reveal" }] + }, + { + "key": "scroll-track", + "trigger": "viewProgress", + "effects": [{ "key": "layer-cta", "effectId": "cta-reveal" }] + } + ] + }, + + "controls": [ + { + "id": "scroll-depth", + "label": "Scroll Depth", + "description": "How much scrolling is required to complete the reveal sequence.", + "group": "Layout", + "type": "select", + "defaultValue": "medium", + "constraints": { + "options": [ + { "value": "short", "label": "Short" }, + { "value": "medium", "label": "Medium" }, + { "value": "long", "label": "Long" } + ] + }, + "bindings": [ + { + "target": "element", + "targetId": "scroll-track", + "property": "styles.height", + "transform": { + "type": "map", + "entries": { "short": "200vh", "medium": "300vh", "long": "500vh" } + } + } + ] + }, + { + "id": "mood", + "label": "Mood", + "description": "Sets the overall visual tone — affects overlay, zoom, easing, and text treatment together.", + "group": "Style", + "type": "select", + "defaultValue": "cinematic", + "constraints": { + "options": [ + { "value": "cinematic", "label": "Cinematic" }, + { "value": "minimal", "label": "Minimal" }, + { "value": "energetic", "label": "Energetic" } + ] + }, + "bindings": [ + { + "target": "element", + "targetId": "overlay", + "property": "styles.background", + "transform": { + "type": "map", + "entries": { + "cinematic": "rgba(0, 0, 0, 0.5)", + "minimal": "rgba(0, 0, 0, 0.1)", + "energetic": "rgba(0, 0, 0, 0.3)" + } + } + }, + { + "target": "effect", + "targetId": "bg-zoom", + "property": "namedEffect.scale", + "transform": { + "type": "map", + "entries": { "cinematic": 1.4, "minimal": 1.05, "energetic": 1.6 } + } + }, + { + "target": "effect", + "targetId": "headline-reveal", + "property": "easing", + "transform": { + "type": "map", + "entries": { + "cinematic": "cubic-bezier(0.16, 1, 0.3, 1)", + "minimal": "ease-in-out", + "energetic": "cubic-bezier(0.34, 1.56, 0.64, 1)" + } + } + }, + { + "target": "effect", + "targetId": "details-reveal", + "property": "easing", + "transform": { + "type": "map", + "entries": { + "cinematic": "cubic-bezier(0.16, 1, 0.3, 1)", + "minimal": "ease-in-out", + "energetic": "cubic-bezier(0.34, 1.56, 0.64, 1)" + } + } + }, + { + "target": "effect", + "targetId": "cta-reveal", + "property": "easing", + "transform": { + "type": "map", + "entries": { + "cinematic": "cubic-bezier(0.16, 1, 0.3, 1)", + "minimal": "ease-in-out", + "energetic": "cubic-bezier(0.34, 1.56, 0.64, 1)" + } + } + } + ] + } + ], + + "disableWhen": [ + { "mediaQuery": "(max-width: 767px)", "label": "Mobile devices" }, + { "mediaQuery": "(prefers-reduced-motion: reduce)", "label": "Reduced motion" } + ], + + "meta": { + "category": "storytelling", + "tags": ["sticky", "scroll", "reveal", "layered", "fullscreen"] + } +} +``` + +--- + +### Example 3: Horizontal Snap Scroll Gallery + +A horizontally scrolling gallery with CSS scroll snapping and scroll-driven entrance effects per item. Demonstrates `scroll-snap-type`, `scroll-snap-align`, responsive overrides via the `styles` array, per-item scroll effects using `listContainer`, and **CSS custom property bindings** where a single `--exp-spacing` variable drives `gap`, container `padding`, and caption `padding` proportionally via `calc()`. + +```json +{ + "$schema": "interact-experience/1.0", + "id": "snap-scroll-gallery", + "name": "Snap Scroll Gallery", + "description": "Horizontal scrolling gallery with snap points and scroll-driven item entrance.", + + "elements": { + "gallery": { + "selector": ".gallery-container", + "styles": { + "display": "flex", + "overflow-x": "auto", + "scroll-snap-type": "x mandatory", + "scroll-behavior": "smooth", + "gap": "calc(var(--exp-spacing) * 1px)", + "padding": "calc(var(--exp-spacing) * 1.67px) calc(var(--exp-spacing) * 1px)", + "-webkit-overflow-scrolling": "touch", + "scrollbar-width": "none" + } + }, + "gallery-item": { + "selector": ".gallery-container > .gallery-item", + "styles": { + "flex": "0 0 80vw", + "scroll-snap-align": "center", + "border-radius": "calc(var(--exp-radius) * 1px)", + "overflow": "hidden", + "background": "#f5f5f5" + } + }, + "item-image": { + "selector": ".gallery-item .item-image", + "styles": { + "width": "100%", + "aspect-ratio": "3 / 2", + "object-fit": "cover", + "display": "block" + } + }, + "item-caption": { + "selector": ".gallery-item .item-caption", + "styles": { + "padding": "calc(var(--exp-spacing) * 0.67px) calc(var(--exp-spacing) * 0.83px)", + "font-size": "1rem" + } + } + }, + + "styles": [ + { + "selector": ".gallery-container::-webkit-scrollbar", + "properties": { "display": "none" } + }, + { + "selector": ".gallery-container > .gallery-item", + "properties": { "flex": "0 0 40vw" }, + "mediaQuery": "(min-width: 1024px)" + }, + { + "selector": ".gallery-container", + "properties": { + "scroll-snap-type": "x proximity" + }, + "mediaQuery": "(max-width: 480px)" + }, + { + "selector": ".gallery-container > .gallery-item", + "properties": { "flex": "0 0 90vw" }, + "mediaQuery": "(max-width: 480px)" + } + ], + + "interact": { + "effects": { + "item-entrance": { + "namedEffect": { "type": "FadeScroll", "range": "in", "opacity": 0.3 }, + "fill": "both" + }, + "item-scale": { + "namedEffect": { + "type": "GrowScroll", + "scale": 0.95, + "direction": "center", + "range": "in" + }, + "fill": "both" + } + }, + "interactions": [ + { + "key": "gallery", + "trigger": "viewProgress", + "effects": [ + { + "listContainer": ".gallery-container", + "listItemSelector": ".gallery-item", + "effectId": "item-entrance" + }, + { + "listContainer": ".gallery-container", + "listItemSelector": ".gallery-item", + "effectId": "item-scale" + } + ] + } + ] + }, + + "controls": [ + { + "id": "snap-alignment", + "label": "Snap Alignment", + "group": "Layout", + "type": "select", + "defaultValue": "center", + "constraints": { + "options": [ + { "value": "start", "label": "Start" }, + { "value": "center", "label": "Center" }, + { "value": "end", "label": "End" } + ] + }, + "bindings": [ + { + "target": "element", + "targetId": "gallery-item", + "property": "styles.scroll-snap-align" + } + ] + }, + { + "id": "item-size", + "label": "Item Size", + "group": "Layout", + "type": "select", + "defaultValue": "large", + "constraints": { + "options": [ + { "value": "small", "label": "Small" }, + { "value": "medium", "label": "Medium" }, + { "value": "large", "label": "Large" } + ] + }, + "bindings": [ + { + "target": "element", + "targetId": "gallery-item", + "property": "styles.flex", + "transform": { + "type": "map", + "entries": { + "small": "0 0 60vw", + "medium": "0 0 70vw", + "large": "0 0 80vw" + } + } + } + ] + }, + { + "id": "spacing", + "label": "Spacing", + "description": "Controls gap between items, container padding, and caption padding proportionally via a single CSS variable.", + "group": "Layout", + "type": "range", + "defaultValue": 24, + "constraints": { "min": 8, "max": 48, "step": 4, "unit": "px" }, + "bindings": [ + { + "target": "variable", + "targetId": "--exp-spacing" + } + ] + }, + { + "id": "item-radius", + "label": "Corner Radius", + "description": "Applied to gallery items via a CSS variable, allowing future pseudo-element or child styles to derive from the same value.", + "group": "Style", + "type": "range", + "defaultValue": 16, + "constraints": { "min": 0, "max": 32, "step": 2, "unit": "px" }, + "bindings": [ + { + "target": "variable", + "targetId": "--exp-radius" + } + ] + }, + { + "id": "scroll-fade", + "label": "Scroll Fade", + "description": "How much items fade as they scroll out of the snap center.", + "group": "Animation", + "type": "range", + "defaultValue": 0.3, + "constraints": { "min": 0, "max": 0.8, "step": 0.1 }, + "bindings": [ + { + "target": "effect", + "targetId": "item-entrance", + "property": "namedEffect.opacity" + } + ] + } + ], + + "disableWhen": [{ "mediaQuery": "(prefers-reduced-motion: reduce)", "label": "Reduced motion" }], + + "meta": { + "category": "galleries", + "tags": ["snap-scroll", "horizontal", "gallery", "scroll", "responsive"] + } +} +``` + +--- + +### Example 4: Parallax Hero with Staggered Entrance + +A full-width hero with a parallax background image, staggered text entrance, and a hover-reactive CTA button. + +```json +{ + "$schema": "interact-experience/1.0", + "id": "parallax-hero", + "name": "Parallax Hero", + "description": "Full-width hero with parallax background and staggered text entrance.", + + "elements": { + "hero": { + "selector": ".hero-section", + "styles": { + "position": "relative", + "min-height": "80vh", + "display": "flex", + "align-items": "center", + "justify-content": "center", + "overflow": "hidden" + } + }, + "hero-bg": { + "selector": ".hero-section .hero-bg", + "styles": { + "position": "absolute", + "inset": "-20% 0", + "z-index": "0" + } + }, + "hero-bg-image": { + "selector": ".hero-section .hero-bg img", + "styles": { + "width": "100%", + "height": "100%", + "object-fit": "cover" + } + }, + "hero-content": { + "selector": ".hero-section .hero-content", + "styles": { + "position": "relative", + "z-index": "1", + "text-align": "center", + "color": "#ffffff", + "max-width": "800px", + "padding": "0 24px" + } + }, + "hero-heading": { + "selector": ".hero-section .hero-heading" + }, + "hero-subtitle": { + "selector": ".hero-section .hero-subtitle" + }, + "hero-cta": { + "selector": ".hero-section .hero-cta", + "styles": { + "padding": "14px 32px", + "border": "none", + "border-radius": "8px", + "background": "#3b82f6", + "color": "#ffffff", + "cursor": "pointer" + } + } + }, + + "interact": { + "effects": { + "bg-parallax": { + "namedEffect": { "type": "ImageParallax", "speed": 1.5, "range": "continuous" }, + "fill": "both" + }, + "heading-entrance": { + "namedEffect": { "type": "GlideIn", "direction": "bottom" }, + "duration": 900, + "easing": "ease-out", + "fill": "backwards", + "triggerType": "once" + }, + "subtitle-entrance": { + "namedEffect": { "type": "FadeIn" }, + "duration": 800, + "easing": "ease-out", + "fill": "backwards", + "delay": 200, + "triggerType": "once" + }, + "cta-entrance": { + "namedEffect": { "type": "GlideIn", "direction": "bottom" }, + "duration": 700, + "easing": "ease-out", + "fill": "backwards", + "delay": 400, + "triggerType": "once" + }, + "cta-hover": { + "keyframeEffect": { + "name": "cta-pulse", + "keyframes": [ + { "transform": "scale(1)" }, + { "transform": "scale(1.05)" }, + { "transform": "scale(1)" } + ] + }, + "duration": 300, + "easing": "ease-in-out", + "fill": "both", + "triggerType": "repeat" + } + }, + "conditions": { + "motion-ok": { + "type": "media", + "predicate": "(prefers-reduced-motion: no-preference)" + } + }, + "interactions": [ + { + "key": "hero-bg", + "trigger": "viewProgress", + "conditions": ["motion-ok"], + "effects": [{ "effectId": "bg-parallax" }] + }, + { + "key": "hero-heading", + "trigger": "viewEnter", + "params": { "threshold": 0.2 }, + "conditions": ["motion-ok"], + "effects": [{ "effectId": "heading-entrance" }] + }, + { + "key": "hero-subtitle", + "trigger": "viewEnter", + "params": { "threshold": 0.2 }, + "conditions": ["motion-ok"], + "effects": [{ "effectId": "subtitle-entrance" }] + }, + { + "key": "hero-cta", + "trigger": "viewEnter", + "params": { "threshold": 0.2 }, + "conditions": ["motion-ok"], + "effects": [{ "effectId": "cta-entrance" }] + }, + { + "key": "hero-cta", + "trigger": "hover", + "effects": [{ "effectId": "cta-hover" }] + } + ] + }, + + "controls": [ + { + "id": "parallax-speed", + "label": "Parallax Intensity", + "description": "How much the background moves relative to scroll.", + "group": "Animation", + "type": "range", + "defaultValue": 1.5, + "constraints": { "min": 0.5, "max": 3, "step": 0.1, "unit": "x" }, + "bindings": [ + { + "target": "effect", + "targetId": "bg-parallax", + "property": "namedEffect.speed" + } + ] + }, + { + "id": "entrance-speed", + "label": "Entrance Speed", + "group": "Animation", + "type": "select", + "defaultValue": "medium", + "constraints": { + "options": [ + { "value": "slow", "label": "Slow" }, + { "value": "medium", "label": "Medium" }, + { "value": "fast", "label": "Fast" } + ] + }, + "bindings": [ + { + "target": "effect", + "targetId": "heading-entrance", + "property": "duration", + "transform": { "type": "map", "entries": { "slow": 1400, "medium": 900, "fast": 500 } } + }, + { + "target": "effect", + "targetId": "subtitle-entrance", + "property": "duration", + "transform": { "type": "map", "entries": { "slow": 1200, "medium": 800, "fast": 400 } } + }, + { + "target": "effect", + "targetId": "cta-entrance", + "property": "duration", + "transform": { "type": "map", "entries": { "slow": 1100, "medium": 700, "fast": 350 } } + } + ] + }, + { + "id": "hero-height", + "label": "Section Height", + "group": "Layout", + "type": "select", + "defaultValue": "large", + "constraints": { + "options": [ + { "value": "medium", "label": "Medium" }, + { "value": "large", "label": "Large" }, + { "value": "full", "label": "Full Screen" } + ] + }, + "bindings": [ + { + "target": "element", + "targetId": "hero", + "property": "styles.min-height", + "transform": { + "type": "map", + "entries": { "medium": "60vh", "large": "80vh", "full": "100vh" } + } + } + ] + }, + { + "id": "accent-color", + "label": "Button Color", + "group": "Style", + "type": "color", + "defaultValue": "#3b82f6", + "bindings": [ + { + "target": "element", + "targetId": "hero-cta", + "property": "styles.background" + } + ] + } + ], + + "disableWhen": [{ "mediaQuery": "(max-width: 480px)", "label": "Small mobile screens" }], + + "meta": { + "category": "hero", + "tags": ["parallax", "scroll", "entrance", "hero", "full-width"] + } +} +``` + +--- + +### Example 5: Interactive Product Showcase + +A product card with pointer-tracking 3D tilt, scroll-driven entrance, and hover zoom. Demonstrates multiple trigger types on the same element and multi-binding controls. + +```json +{ + "$schema": "interact-experience/1.0", + "id": "interactive-product-showcase", + "name": "Interactive Product Showcase", + "description": "Product card with 3D tilt on pointer move, scroll entrance, and hover effects.", + + "elements": { + "product-card": { + "selector": ".product-showcase", + "styles": { + "border-radius": "16px", + "overflow": "hidden", + "background": "#ffffff", + "box-shadow": "0 4px 20px rgba(0, 0, 0, 0.06)" + } + }, + "product-image": { + "selector": ".product-showcase .product-image-wrap", + "styles": { + "overflow": "hidden", + "aspect-ratio": "1 / 1" + } + }, + "product-image-el": { + "selector": ".product-showcase .product-image-wrap img", + "styles": { + "width": "100%", + "height": "100%", + "object-fit": "cover" + } + }, + "product-price": { + "selector": ".product-showcase .product-price", + "styles": { + "color": "#3b82f6" + } + } + }, + + "interact": { + "effects": { + "card-entrance": { + "namedEffect": { "type": "GlideIn", "direction": "bottom" }, + "duration": 800, + "easing": "ease-out", + "fill": "backwards", + "triggerType": "once" + }, + "card-tilt": { + "namedEffect": { "type": "Tilt3DMouse", "angle": 8, "perspective": 800 }, + "fill": "both" + }, + "image-zoom": { + "keyframeEffect": { + "name": "product-image-zoom", + "keyframes": [{ "transform": "scale(1)" }, { "transform": "scale(1.08)" }] + }, + "duration": 400, + "easing": "ease-out", + "fill": "both", + "triggerType": "alternate" + } + }, + "conditions": { + "has-hover": { + "type": "media", + "predicate": "(hover: hover)" + } + }, + "interactions": [ + { + "key": "product-card", + "trigger": "viewEnter", + "params": { "threshold": 0.3 }, + "effects": [{ "effectId": "card-entrance" }] + }, + { + "key": "product-card", + "trigger": "pointerMove", + "params": { "hitArea": "self" }, + "conditions": ["has-hover"], + "effects": [{ "effectId": "card-tilt" }] + }, + { + "key": "product-card", + "trigger": "hover", + "conditions": ["has-hover"], + "effects": [ + { + "key": "product-image", + "effectId": "image-zoom" + } + ] + } + ] + }, + + "controls": [ + { + "id": "tilt-intensity", + "label": "3D Tilt", + "description": "Strength of the pointer-tracking tilt effect.", + "group": "Interaction", + "type": "range", + "defaultValue": 8, + "constraints": { "min": 2, "max": 20, "step": 1, "unit": "°" }, + "bindings": [ + { + "target": "effect", + "targetId": "card-tilt", + "property": "namedEffect.angle" + }, + { + "target": "effect", + "targetId": "card-tilt", + "property": "namedEffect.perspective", + "transform": { "type": "linear", "factor": -30, "offset": 1040 } + } + ] + }, + { + "id": "hover-zoom", + "label": "Hover Zoom", + "group": "Interaction", + "type": "range", + "defaultValue": 1.08, + "constraints": { "min": 1, "max": 1.3, "step": 0.01, "unit": "x" }, + "bindings": [ + { + "target": "effect", + "targetId": "image-zoom", + "property": "keyframeEffect.keyframes.1.transform", + "transform": { "type": "template", "template": "scale(${value})" } + } + ] + }, + { + "id": "card-radius", + "label": "Corner Radius", + "group": "Style", + "type": "range", + "defaultValue": 16, + "constraints": { "min": 0, "max": 32, "step": 2, "unit": "px" }, + "bindings": [ + { + "target": "element", + "targetId": "product-card", + "property": "styles.border-radius", + "transform": { "type": "template", "template": "${value}px" } + } + ] + }, + { + "id": "accent-color", + "label": "Accent Color", + "group": "Style", + "type": "color", + "defaultValue": "#3b82f6", + "bindings": [ + { + "target": "element", + "targetId": "product-price", + "property": "styles.color" + } + ] + } + ], + + "disableWhen": [{ "mediaQuery": "(prefers-reduced-motion: reduce)", "label": "Reduced motion" }], + + "meta": { + "category": "e-commerce", + "tags": ["product", "3d-tilt", "pointer", "hover", "scroll"] + } +} +``` + +--- + +## Validation + +An experience must pass the following checks before it is considered valid: + +### Structural Validation + +| Rule | Check | +| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | +| Schema | `$schema` is `'interact-experience/1.0'`. | +| Required fields | `id`, `name`, `elements`, `interact`, `controls` are present; `interact.effects` and `interact.interactions` are present. | +| Unique element keys | No duplicate keys in the `elements` map. | +| Selectors present | Every `ElementEntry` has a non-empty `selector`. | +| No `customEffect` | No effect uses the `customEffect` property. | +| Single effect shape | Each effect in `interact.effects` (or inline) declares exactly one of `namedEffect`, `keyframeEffect`, `transition`, `transitionProperties`. | +| No function offsetEasing | No sequence uses a function for `offsetEasing` (only string easing names). | +| Valid `triggerType` | `triggerType` is one of `'once'` / `'repeat'` / `'alternate'` / `'state'` and appears only on `TimeEffect` or on a sequence. | +| Valid `stateAction` | `stateAction` is one of `'toggle'` / `'add'` / `'remove'` / `'clear'` and appears only on `StateEffect`. | +| Scroll preset `range` | Every `*Scroll` `namedEffect` used with `viewProgress` includes a `range` option of `'in'` / `'out'` / `'continuous'`. | +| Trigger params shape | `params` matches the declared `trigger`: `ViewEnterParams` for `viewEnter`/`pageVisible`, `PointerMoveParams` for `pointerMove`, `AnimationEndParams` for `animationEnd`, absent for `hover`/`click`/`interest`/`activate`/`viewProgress`. | +| No `type` in `params` | `params.type` is NOT used for viewEnter/pageVisible — playback behavior belongs on each effect as `triggerType`. | + +### Referential Integrity + +| Rule | Check | +| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Interaction keys | Every `key` in an interaction exists in the `elements` map. Every `Effect.key` override likewise exists in `elements`. | +| Effect IDs | Every `effectId` referenced in an interaction or sequence exists in `interact.effects`. | +| Sequence IDs | Every `sequenceId` referenced in an interaction exists in `interact.sequences`. | +| `animationEnd` | Every `trigger: 'animationEnd'` interaction has `params.effectId` referencing an existing entry in `interact.effects`. | +| Conditions | Every condition name referenced (in interactions, effects, or sequences) exists in `interact.conditions`. | +| Effects or seqs | Every interaction has at least one of `effects` or `sequences`. | +| Control targets | Every `targetId` in a control binding resolves to an existing target (element key, effect key, style selector, or interaction `id`). Does not apply to `variable` bindings. | +| Style selectors | Every `targetId` with `target: 'style'` matches a `selector` in the `styles` array. | +| Variable names | Every `targetId` with `target: 'variable'` is a valid CSS custom property name (starts with `--`). | +| Property required | `property` is required for `effect`, `style`, `element`, and `interaction` bindings. | + +### Control Validation + +| Rule | Check | +| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| Default in range | For `range` controls, `defaultValue` is within `[min, max]`. | +| Default in options | For `select` controls, `defaultValue` matches one of the option values. | +| Unique IDs | No two controls share the same `id`. | +| Valid transform type | Every `transform.type` is one of `direct`, `linear`, `inverse`, `map`, `template`. | +| Map coverage | For `map` transforms on `select` controls, every option value has an entry. | +| Variable usage | Every CSS custom property set via a `variable` binding should be referenced by at least one `var()` in element styles or the `styles` array. | + +### Condition Validation + +| Rule | Check | +| ------------------- | ------------------------------------------------------------------------------------ | +| Valid media queries | Every `mediaQuery` in `disableWhen` is a syntactically valid CSS media query string. | + +--- + +## Rendering Pipeline (Informational) + +This section sketches how a renderer would consume an experience. It is not normative. + +``` +1. Check conditions → Evaluate disableWhen against window.matchMedia(). + If any matches, skip all remaining steps. + Set up listeners to react to changes. +2. Apply controls → Resolve controls: merge user overrides with defaults, + walk bindings, apply transforms, write final values + into elements, styles, interact config, and CSS + custom properties. For 'variable' bindings, set + --custom-properties on the experience scope element. +3. Select elements → For each entry in elements, query the DOM using the + selector. Set data-interact-key to the element key. + Apply the element's styles to the matched element. +4. Inject CSS → Create a scoped stylesheet from the styles array. + Element styles and style rules may reference CSS + custom properties set in step 2 via var() / calc(). +5. Init Interact → Register required named effects from @wix/motion-presets. + Build InteractConfig from effects, sequences, conditions, + and interactions. Call Interact.create(config). + Call add() for each element that was found. +6. Controls UI → Render the controls panel from the controls array. + On change, re-apply step 2 and update the live + experience. +7. Teardown → If a disableWhen condition starts matching, destroy + the Interact instance, remove injected styles, + and unset data-interact-key attributes. +``` + +--- + +## Open Questions + +| # | Question | Options | +| --- | --------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| 1 | Should experiences support **nested composition** (embedding one experience inside another)? | Could add a `children` field that references other experience IDs. | +| 2 | Should controls support **conditional visibility** (show control B only when control A is set to X)? | Could add a `visibleWhen` field to controls. | +| 3 | Should the `styles` array support **CSS animations and transitions** directly, or should all animation go through Interact? | Keeping all motion in Interact is cleaner, but CSS transitions for simple hover states may be more practical. | +| 4 | Should there be a **compact binary format** for storage/transfer alongside the JSON format? | JSON is fine for v1; binary can be added later if size becomes an issue. | +| 5 | Should `disableWhen` support non-media conditions (e.g. feature detection, user preference flags)? | Could extend `MediaCondition` to a union with other condition types. | +| 6 | Should element entries support **multiple selectors** (e.g. for selecting the same logical element across layout variants)? | Could allow `selector` to be `string \| string[]`. |