diff --git a/packages/interact/dev/experience-spec.md b/packages/interact/dev/experience-spec.md new file mode 100644 index 0000000..6113173 --- /dev/null +++ b/packages/interact/dev/experience-spec.md @@ -0,0 +1,1829 @@ +# 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` and `keyframeEffect` are permitted as effect sources. + +```ts +type ExperienceInteractConfig = { + effects: Record; + conditions?: Record; + interactions: ExperienceInteraction[]; +}; +``` + +#### Serializable Effects + +```ts +type SerializableEffectSource = + | { namedEffect: NamedEffect } + | { keyframeEffect: { name: string; keyframes: Keyframe[] } }; + +type SerializableTimeEffect = SerializableEffectSource & { + duration: number; + easing?: string; + iterations?: number; + alternate?: boolean; + fill?: 'none' | 'forwards' | 'backwards' | 'both'; + reversed?: boolean; + delay?: number; +}; + +type SerializableScrubEffect = SerializableEffectSource & { + easing?: string; + iterations?: number; + alternate?: boolean; + fill?: 'none' | 'forwards' | 'backwards' | 'both'; + reversed?: boolean; + rangeStart?: RangeOffset; + rangeEnd?: RangeOffset; + transitionDuration?: number; + transitionEasing?: ScrubTransitionEasing; + centeredToTarget?: boolean; +}; + +type SerializableEffect = EffectBase & + (SerializableTimeEffect | SerializableScrubEffect | TransitionEffect); +``` + +#### Interactions + +Interactions follow the existing `Interaction` type. The `key` field references an element key from the `elements` map. + +```ts +type ExperienceInteraction = { + id?: string; + key: string; + trigger: TriggerType; + params?: TriggerParams; + conditions?: string[]; + selector?: string; + listContainer?: string; + listItemSelector?: string; + effects: (SerializableEffect | { effectId: string })[]; +}; +``` + +#### Named Effect Types + +All existing named effect types from `@wix/motion-presets` are available: + +| Category | Types | +| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Entrance** | `FadeIn`, `SlideIn`, `GlideIn`, `FloatIn`, `FlipIn`, `FoldIn`, `SpinIn`, `BounceIn`, `DropIn`, `ArcIn`, `CurveIn`, `TurnIn`, `WinkIn`, `TiltIn`, `ShapeIn`, `ShuttersIn`, `RevealIn`, `BlurIn`, `ExpandIn` | +| **Ongoing** | `Pulse`, `Spin`, `Breathe`, `Poke`, `Flash`, `Swing`, `Flip`, `Rubber`, `Fold`, `Jello`, `Wiggle`, `Bounce`, `Cross`, `DVD`, `Blink` | +| **Scroll** | `ParallaxScroll`, `FadeScroll`, `BlurScroll`, `GrowScroll`, `ShrinkScroll`, `MoveScroll`, `PanScroll`, `SlideScroll`, `SpinScroll`, `Spin3dScroll`, `FlipScroll`, `ArcScroll`, `RevealScroll`, `ShapeScroll`, `ShuttersScroll`, `SkewPanScroll`, `StretchScroll`, `TiltScroll`, `TurnScroll` | +| **Mouse** | `TrackMouse`, `AiryMouse`, `BlobMouse`, `BlurMouse`, `BounceMouse`, `ScaleMouse`, `SkewMouse`, `SpinMouse`, `SwivelMouse`, `Tilt3DMouse`, `Track3DMouse` | +| **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. + +--- + +## 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 condition referenced must be defined** in the `interact.conditions` map. +8. **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` | +| 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' }` | +| Directional entrance | `namedEffect: { type: 'SlideIn', direction: '...' }` or `GlideIn` | +| Scroll-driven parallax | `namedEffect: { type: 'ParallaxScroll', parallaxFactor: ... }` | +| Hover micro-interaction | `keyframeEffect` with `transform: scale(...)` or named `Pulse` | +| Background scroll effect | `namedEffect: { type: 'BgParallax', speed: ... }` | +| Custom keyframe motion | `keyframeEffect: { name: '...', keyframes: [...] }` | +| Pointer-tracking | `namedEffect: { type: 'TrackMouse', distance: ... }` | + +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, 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" + } + }, + "interactions": [ + { + "key": "card", + "trigger": "viewEnter", + "params": { "type": "once", "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" }, + "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 }, + "fill": "both" + }, + "heading-entrance": { + "namedEffect": { "type": "GlideIn", "direction": "bottom" }, + "duration": 900, + "easing": "ease-out", + "fill": "backwards" + }, + "subtitle-entrance": { + "namedEffect": { "type": "FadeIn" }, + "duration": 800, + "easing": "ease-out", + "fill": "backwards", + "delay": 200 + }, + "cta-entrance": { + "namedEffect": { "type": "GlideIn", "direction": "bottom" }, + "duration": 700, + "easing": "ease-out", + "fill": "backwards", + "delay": 400 + }, + "cta-hover": { + "keyframeEffect": { + "name": "cta-pulse", + "keyframes": [ + { "transform": "scale(1)" }, + { "transform": "scale(1.05)" }, + { "transform": "scale(1)" } + ] + }, + "duration": 300, + "easing": "ease-in-out" + } + }, + "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": { "type": "once", "threshold": 0.2 }, + "conditions": ["motion-ok"], + "effects": [{ "effectId": "heading-entrance" }] + }, + { + "key": "hero-subtitle", + "trigger": "viewEnter", + "params": { "type": "once", "threshold": 0.2 }, + "conditions": ["motion-ok"], + "effects": [{ "effectId": "subtitle-entrance" }] + }, + { + "key": "hero-cta", + "trigger": "viewEnter", + "params": { "type": "once", "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" + }, + "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" + } + }, + "conditions": { + "has-hover": { + "type": "media", + "predicate": "(hover: hover)" + } + }, + "interactions": [ + { + "key": "product-card", + "trigger": "viewEnter", + "params": { "type": "once", "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. | +| 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. | + +### Referential Integrity + +| Rule | Check | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Interaction keys | Every `key` in an interaction exists in the `elements` map. | +| Effect IDs | Every `effectId` referenced in an interaction exists in `interact.effects`. | +| Conditions | Every condition name referenced exists in `interact.conditions`. | +| 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. + 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 | How should **sequences/staggering** (per the existing sequences-spec) integrate with this model? | Could be expressed through the existing `sequences` proposal on `InteractConfig`. | +| 5 | 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. | +| 6 | Should `disableWhen` support non-media conditions (e.g. feature detection, user preference flags)? | Could extend `MediaCondition` to a union with other condition types. | +| 7 | Should element entries support **multiple selectors** (e.g. for selecting the same logical element across layout variants)? | Could allow `selector` to be `string | string[]`. |