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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

## @wix/interact-validate

### [Unreleased]

#### Added

- `reducedMotionHandling` accepted on interactions, sequences and effects, with checks that a named effect exists and is of the same kind (`REDUCED_MOTION_EFFECT_NOT_FOUND`, `REDUCED_MOTION_INCOMPATIBLE_EFFECT`) and the `REDUCED_MOTION` rule category (`REDUCED_MOTION_SELF_REFERENCE`, `REDUCED_MOTION_SHADOWED_EFFECT_ID`, `REDUCED_MOTION_ANIMATION_END_SOURCE_MISMATCH`, `REDUCED_MOTION_COLLAPSE_CANCELS`)
- An effect named only by a `reducedMotionHandling` is no longer reported as `UNUSED_EFFECT`

### [0.2.1] - 2026-10-25

#### Added
Expand Down Expand Up @@ -112,6 +119,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

## @wix/interact

### [Unreleased]

#### Added

- `reducedMotionHandling` on interactions, sequences and effects — `'collapse'` (default), `'ignore'`, `'cancel'` or an effectId — sets what an effect does under `prefers-reduced-motion: reduce`; the most specific level wins. `generate()` emits both outcomes under exclusive `prefers-reduced-motion` media, and the runtime picks the matching one, without adding effects to the config
- `reducedMotionHandling` and `forceReducedMotion` options for `Interact.setup()` (every instance), `Interact.create()` (one instance) and `generate()` (defaulting to `Interact.setup()`'s)

#### Changed

- **Breaking:** under `prefers-reduced-motion: reduce`, effects collapse by default: they play with duration 0, keeping their delay and iterations, so they jump to their end state. Looping, `viewProgress` and `pointerMove` effects are dropped, and sequence staggers collapse. Pass `reducedMotionHandling: 'ignore'` to `Interact.setup()` to keep the old behavior
- **Breaking:** `Interact.forceReducedMotion` applies the reduced-motion outcome of `reducedMotionHandling` instead of passing `reducedMotion` to `@wix/motion`. With the default `'collapse'` that matches the old behavior, except that finite multi-iteration animations collapse to their end state instead of being dropped
- Runtime transitions collapse under `prefers-reduced-motion: reduce` through `reducedMotionHandling`, matching `generate()`, instead of always being wrapped in `prefers-reduced-motion: no-preference`
- `reducedMotion` removed from `InteractOptions`, and the `context` argument from `Interact.getSequence()` / `Interact.addToSequence()`
- 0ms transitions that have a delay are kept, so the delay is honored

### [2.6.0] - 2026-11-02

#### Changed
Expand Down
3 changes: 0 additions & 3 deletions apps/website/assets/main.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1739,9 +1739,6 @@ updateTunnelBounds();
window.addEventListener('resize', buildGrid);
window.addEventListener('resize', updateTunnelBounds);

// Respect reduced motion settings
Interact.forceReducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;

// Register presets
Interact.registerEffects(presets);

Expand Down
319 changes: 319 additions & 0 deletions docs/reduced-motion-design.md

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions packages/interact-validate/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ const RULE_CODE_MAP: Record<string, string> = {
FUNCTION_OFFSET_EASING: 'OFFSET_EASING',
NAMED_EFFECT_UNKNOWN_PARAM: 'NAMED_EFFECT_PARAMS',
NAMED_EFFECT_INVALID_PARAM: 'NAMED_EFFECT_PARAMS',
REDUCED_MOTION_SELF_REFERENCE: 'REDUCED_MOTION',
REDUCED_MOTION_COLLAPSE_CANCELS: 'REDUCED_MOTION',
REDUCED_MOTION_ANIMATION_END_SOURCE_MISMATCH: 'REDUCED_MOTION',
REDUCED_MOTION_SHADOWED_EFFECT_ID: 'REDUCED_MOTION',
};

export function finalize(
Expand Down
3 changes: 2 additions & 1 deletion packages/interact-validate/src/schema/effects.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { z } from 'zod';
import { Keyframe, RangeOffset } from './primitives';
import { Keyframe, RangeOffset, ReducedMotionHandling } from './primitives';
import { withPluginFields } from './plugins';

export const StateActionType = z.enum(['add', 'remove', 'toggle', 'clear']);
Expand Down Expand Up @@ -131,6 +131,7 @@ const EffectBase = {
listContainer: z.string().optional(),
listItemSelector: z.string().optional(),
conditions: z.array(z.string().min(1)).optional(),
reducedMotionHandling: ReducedMotionHandling.optional(),
};

export const StateEffect = withPluginFields(
Expand Down
12 changes: 10 additions & 2 deletions packages/interact-validate/src/schema/interactions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { z } from 'zod';
import { Condition } from './primitives';
import { Condition, ReducedMotionHandling } from './primitives';
import {
Effect,
ViewProgressEffect,
Expand All @@ -17,6 +17,7 @@ import { withPluginFields } from './plugins';
import type { Path, SemanticIssue } from '../types';
import { walkConfig } from '../walkConfig';
import { collectSemanticWarnings } from '../semantic';
import { checkReducedMotionErrors } from '../semantic/reducedMotion';

export const TriggerType = z.enum([
'hover',
Expand Down Expand Up @@ -58,6 +59,7 @@ const InteractionBase = {
listContainer: z.string().optional(),
listItemSelector: z.string().optional(),
conditions: z.array(z.string().min(1)).optional(),
reducedMotionHandling: ReducedMotionHandling.optional(),
};

const hasEffectsOrSequences = (interaction: { effects?: unknown[]; sequences?: unknown[] }) =>
Expand Down Expand Up @@ -247,11 +249,14 @@ export const InteractConfigSchema = z
onInteraction: (path, interaction) => {
validateConditionReferences(ctx, path, configConditions, interaction.conditions);
},
onEffect: (path, effect, isTopLevel) => {
onEffect: (path, effect, isTopLevel, owner, sequence) => {
validateConditionReferences(ctx, path, configConditions, effect.conditions);
if (!isTopLevel) {
validateEffectReference(ctx, path, effect, configEffects);
}
checkReducedMotionErrors(config, path, effect, isTopLevel, owner, sequence).forEach(
({ severity: _, ...issue }) => ctx.addIssue(issue as any),
);
},
onSequence: (path, sequence, isTopLevel) => {
validateConditionReferences(ctx, path, configConditions, sequence.conditions);
Expand Down Expand Up @@ -280,9 +285,11 @@ export const InteractConfigSchema = z
walkConfig(config, {
onInteraction: (_path, interaction) => {
interaction.conditions?.forEach(Set.prototype.delete, conditionReferences);
effectIdReferences.delete(interaction.reducedMotionHandling!);
},
onEffect: (path, effect, isTopLevel) => {
effect.conditions?.forEach(Set.prototype.delete, conditionReferences);
effectIdReferences.delete(effect.reducedMotionHandling!);
collectEffectKeyframeNames(warnings, path, keyframeNames, (effect as any).keyframeEffect);
if (!isTopLevel && effect.effectId) {
if (!configEffects[effect.effectId]) {
Expand All @@ -298,6 +305,7 @@ export const InteractConfigSchema = z
},
onSequence: (_path, sequence, isTopLevel) => {
sequence.conditions?.forEach(Set.prototype.delete, conditionReferences);
effectIdReferences.delete(sequence.reducedMotionHandling!);
if (!isTopLevel && sequence.sequenceId) {
sequenceIdReferences.delete(sequence.sequenceId);
}
Expand Down
5 changes: 5 additions & 0 deletions packages/interact-validate/src/schema/primitives.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { z } from 'zod';

export const ReducedMotionMode = z.enum(['ignore', 'cancel', 'collapse']);

// a string other than the modes is an effectId in config.effects
export const ReducedMotionHandling = z.union([ReducedMotionMode, z.string().min(1)]);

export const Keyframe = z.record(z.string(), z.union([z.string(), z.number()]));

export const LengthPercentage = z
Expand Down
2 changes: 2 additions & 0 deletions packages/interact-validate/src/schema/sequences.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { z } from 'zod';
import { TimeEffect, TimeEffectRef, TimeTriggerType } from './effects';
import { ReducedMotionHandling } from './primitives';

const SequenceOptionsConfig = {
delay: z.number().int().nonnegative().optional(),
Expand All @@ -10,6 +11,7 @@ const SequenceOptionsConfig = {
triggerType: TimeTriggerType.optional(),
sequenceId: z.string().optional(),
conditions: z.array(z.string().min(1)).optional(),
reducedMotionHandling: ReducedMotionHandling.optional(),
};

export const SequenceConfig = z
Expand Down
42 changes: 8 additions & 34 deletions packages/interact-validate/src/semantic/collectSemanticWarnings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
// inspect the parsed config shape only. Warning/info checks are collected by
// `collectSemanticWarnings` (consumed by the schema `transform`).

import type { Path, SemanticIssue, AnyConfig, Visitors } from '../types';
import type { SemanticIssue, AnyConfig } from '../types';
import { walkConfig } from '../walkConfig';
import { checkFunctionOffsetEasing } from './cssGeneration';
import { checkCSSPropertyNames, checkInvalidInset } from './cssSyntax';
import { checkSameElementRetrigger, checkHitAreaShift } from './fouc';
Expand All @@ -16,51 +17,23 @@ import { checkEmptyStyleProperties, checkStateRemoveWithoutEffectId } from './pa
import { checkRecommendedFill } from './recommendedPatterns';
import { findAnimationEndWarnings } from './animationEndGraph';
import { checkNamedEffectParams } from './namedEffectParams';

// Single traversal of top-level registry effects/sequences and per-interaction
// effects/sequences, supplying each node's `path`, whether it is a top-level
// registry definition, and its owning interaction (when known).
export function walkConfig(config: AnyConfig, visitors: Visitors): void {
const { onInteraction, onEffect, onSequence } = visitors;

Object.entries(config.effects ?? {}).forEach(([id, effect]) => {
onEffect(['effects', id], effect, true, undefined);
});

Object.entries(config.sequences ?? {}).forEach(([id, sequence]) => {
onSequence(['sequences', id], sequence, true, undefined);
sequence.effects?.forEach((effect, ei) => {
onEffect(['sequences', id, 'effects', ei], effect, false, undefined);
});
});

config.interactions.forEach((interaction, i) => {
onInteraction?.(['interactions', i], interaction);
interaction.effects?.forEach((effect, ei) => {
onEffect(['interactions', i, 'effects', ei], effect, false, interaction);
});
interaction.sequences?.forEach((sequence, si) => {
const seqPath: Path = ['interactions', i, 'sequences', si];
onSequence(seqPath, sequence, false, interaction);
sequence.effects?.forEach((effect, ei) => {
onEffect([...seqPath, 'effects', ei], effect, false, interaction);
});
});
});
}
import { createReducedMotionWarnings } from './reducedMotion';

// Collect every warning/info-level semantic issue (consumed by `transform`).
export function collectSemanticWarnings(config: AnyConfig): SemanticIssue[] {
const reducedMotion = createReducedMotionWarnings(config);
const warnings: SemanticIssue[] = [];

walkConfig(config, {
onInteraction: (path, interaction) => {
warnings.push(...reducedMotion.onInteraction(path, interaction));
warnings.push(...checkListItemSelectorWithoutContainer(path, interaction));
warnings.push(...checkRedundantSelector(path, interaction));
warnings.push(...checkInvalidInset(path, interaction));
},
// checks that only look at depth>1 properties in effects/sequences do not need resolving
onEffect: (path, effect, isTopLevel, owner) => {
onEffect: (path, effect, isTopLevel, owner, sequence) => {
warnings.push(...reducedMotion.onEffect(path, effect, isTopLevel, owner, sequence));
Comment thread
ameerf-wix marked this conversation as resolved.
Resolved
const { effectId } = effect;
const resolvedEffect =
!isTopLevel && effectId
Expand All @@ -78,6 +51,7 @@ export function collectSemanticWarnings(config: AnyConfig): SemanticIssue[] {
warnings.push(...checkNamedEffectParams(path, effect));
},
onSequence: (path, sequence, isTopLevel, owner) => {
warnings.push(...reducedMotion.onSequence(path, sequence, isTopLevel, owner));
Comment thread
ameerf-wix marked this conversation as resolved.
Resolved
const { sequenceId } = sequence;
const resolvedSequence =
!isTopLevel && sequenceId
Expand Down
8 changes: 8 additions & 0 deletions packages/interact-validate/src/semantic/issue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { Path, SemanticIssue } from '../types';

export const issue = (
path: Path,
domainCode: string,
message: string,
severity: SemanticIssue['severity'],
) => ({ code: 'custom', path, message, params: { domainCode }, severity }) as SemanticIssue;
8 changes: 1 addition & 7 deletions packages/interact-validate/src/semantic/namedEffectParams.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Interact } from '@wix/interact';
import type { AnyEffect, EffectParamSchema, EffectSchema, Path, SemanticIssue } from '../types';
import { issue } from './issue';

const LENGTH_REGEX = /^-?\d*\.?\d+(px|%|em|rem|vw|vh|vmin|vmax|ch|ex|cm|mm|in|pt|pc)?$/i;
const ANGLE_REGEX = /^-?\d*\.?\d+(deg|rad|grad|turn)?$/i;
Expand Down Expand Up @@ -58,13 +59,6 @@ const PARAM_TYPES: Record<
},
};

const issue = (
path: Path,
domainCode: string,
message: string,
severity: SemanticIssue['severity'],
) => ({ code: 'custom', path, message, params: { domainCode }, severity }) as SemanticIssue;

// the namedEffect's params against the schema of the effect registered on Interact under its type
export function checkNamedEffectParams(path: Path, effect: AnyEffect): SemanticIssue[] {
const { type, ...params } = effect.namedEffect ?? {};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Path, SemanticIssue, AnyEffect, AnyInteraction } from '../types';
import { RETRIGGER_TYPES } from '../types';
import { RETRIGGER_TYPES, SCRUB_TRIGGERS } from '../types';

function isKeyframeEffect(effect: AnyEffect): boolean {
return !!(effect.namedEffect || effect.keyframeEffect);
Expand All @@ -12,7 +12,7 @@ export function checkRecommendedFill(
owner?: AnyInteraction,
): SemanticIssue[] {
if (effect.fill === 'both') return [];
const isScrubbed = owner?.trigger === 'viewProgress' || owner?.trigger === 'pointerMove';
const isScrubbed = !!owner?.trigger && SCRUB_TRIGGERS.includes(owner.trigger);
const isToggling =
effect.triggerType !== undefined && RETRIGGER_TYPES.includes(effect.triggerType);

Expand Down
Loading
Loading