diff --git a/.cursor/plans/sequence_feature_implementation_84c01c97.plan.md b/.cursor/plans/sequence_feature_implementation_84c01c97.plan.md
index 07b6e45..3b322c0 100644
--- a/.cursor/plans/sequence_feature_implementation_84c01c97.plan.md
+++ b/.cursor/plans/sequence_feature_implementation_84c01c97.plan.md
@@ -108,6 +108,8 @@ classDiagram
Sequence "1" --> "*" AnimationGroup : manages
```
+
+
## Part 1: @wix/motion Package Changes
### 1.1 Create Sequence Class
@@ -265,7 +267,7 @@ Modify `packages/interact/src/core/Interact.ts`:
- Or a single `effect: Effect` declaration, generating a list of effects on multiple target elements
- Generate unique IDs for sequence effects
-3. Track sequence membership for effects (needed for delay calculation)
+1. Track sequence membership for effects (needed for delay calculation)
### 2.4 Update Effect Processing in `add.ts`
@@ -325,3 +327,4 @@ The calculated offsets are added to each effect's existing `delay` property.
2. Unit tests for easing function integration
3. Integration tests for sequence parsing in Interact
4. E2E tests for staggered animations with various easing functions
+
diff --git a/apps/demo/src/styles.css b/apps/demo/src/styles.css
index 9b38972..8f5e7f5 100644
--- a/apps/demo/src/styles.css
+++ b/apps/demo/src/styles.css
@@ -548,3 +548,306 @@ body {
background: linear-gradient(135deg, #ff9a9e 0%, #fecfef 99%, #fecfef 100%);
color: #333;
}
+
+/* Sequence Demo */
+.sequence-demo-section {
+ margin-top: 2rem;
+}
+
+.sequence-demo-header {
+ margin-bottom: 1.5rem;
+}
+
+.sequence-demo-header h3 {
+ margin: 0 0 0.5rem;
+ font-size: 1.5rem;
+}
+
+.sequence-demo-description {
+ color: rgb(148 163 184 / 0.9);
+ line-height: 1.6;
+ margin: 0;
+}
+
+.sequence-demo-description code {
+ background: rgb(30 41 59 / 0.8);
+ padding: 0.15em 0.4em;
+ border-radius: 4px;
+ font-family: 'JetBrains Mono', 'Fira Code', monospace;
+ font-size: 0.9em;
+ color: #a5b4fc;
+}
+
+.easing-presets {
+ margin-bottom: 1.5rem;
+}
+
+.easing-preset-buttons {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ margin-top: 0.5rem;
+}
+
+.easing-preset-btn {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.15rem;
+ padding: 0.5rem 0.75rem;
+ background: rgb(30 41 59 / 0.6);
+ border: 1px solid rgb(148 163 184 / 0.2);
+ border-radius: 8px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.easing-preset-btn:hover {
+ background: rgb(30 41 59 / 0.9);
+ border-color: rgb(148 163 184 / 0.4);
+}
+
+.easing-preset-btn.active {
+ background: linear-gradient(135deg, rgba(59, 130, 246, 0.3) 0%, rgba(139, 92, 246, 0.3) 100%);
+ border-color: #8b5cf6;
+}
+
+.preset-name {
+ font-family: 'JetBrains Mono', 'Fira Code', monospace;
+ font-size: 0.85rem;
+ font-weight: 600;
+ color: #e2e8f0;
+}
+
+.preset-type {
+ font-size: 0.65rem;
+ color: #94a3b8;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.sequence-demo-layout {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 2rem;
+ margin-bottom: 1.5rem;
+}
+
+@media (max-width: 900px) {
+ .sequence-demo-layout {
+ grid-template-columns: 1fr;
+ }
+}
+
+.sequence-config-editor {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.sequence-config-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+}
+
+.parse-error {
+ font-size: 0.75rem;
+ color: #f87171;
+ background: rgba(248, 113, 113, 0.1);
+ padding: 0.25rem 0.5rem;
+ border-radius: 4px;
+}
+
+.config-textarea {
+ width: 100%;
+ min-height: 400px;
+ padding: 1rem;
+ background: rgb(2 6 23 / 0.9);
+ border: 1px solid rgb(148 163 184 / 0.2);
+ border-radius: 8px;
+ font-family: 'JetBrains Mono', 'Fira Code', monospace;
+ font-size: 0.8rem;
+ line-height: 1.6;
+ color: #a5b4fc;
+ resize: vertical;
+ tab-size: 2;
+}
+
+.config-textarea:focus {
+ outline: none;
+ border-color: rgb(59 130 246 / 0.5);
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
+}
+
+.sequence-buttons {
+ display: flex;
+ gap: 0.75rem;
+}
+
+.sequence-apply-button,
+.sequence-reset-button {
+ flex: 1;
+ padding: 0.75rem 1.5rem;
+ background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
+ border: none;
+ border-radius: 8px;
+ color: white;
+ font-weight: 600;
+ font-size: 0.9rem;
+ cursor: pointer;
+ transition:
+ transform 0.2s ease,
+ box-shadow 0.2s ease;
+}
+
+.sequence-reset-button {
+ background: rgb(71 85 105 / 0.8);
+}
+
+.sequence-apply-button:hover,
+.sequence-reset-button:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 8px 20px rgba(59, 130, 246, 0.3);
+}
+
+.sequence-apply-button:active,
+.sequence-reset-button:active {
+ transform: translateY(0);
+}
+
+.sequence-demo-preview {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.sequence-trigger-area {
+ background: rgb(30 41 59 / 0.5);
+ border: 2px dashed rgb(148 163 184 / 0.3);
+ border-radius: 1rem;
+ padding: 1.5rem;
+ cursor: pointer;
+ transition:
+ border-color 0.2s ease,
+ background 0.2s ease;
+}
+
+.sequence-trigger-area:hover {
+ border-color: rgb(59 130 246 / 0.5);
+ background: rgb(30 41 59 / 0.7);
+}
+
+.sequence-trigger-hint {
+ text-align: center;
+ color: rgb(148 163 184 / 0.7);
+ font-size: 0.85rem;
+ margin: 0 0 1rem;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+}
+
+.sequence-items-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 0.75rem;
+}
+
+@media (max-width: 600px) {
+ .sequence-items-grid {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+
+.sequence-item {
+ aspect-ratio: 1;
+ min-width: 80px;
+ background: linear-gradient(
+ 135deg,
+ var(--item-color) 0%,
+ color-mix(in srgb, var(--item-color) 70%, white) 100%
+ );
+ border-radius: 12px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-shadow: 0 4px 20px color-mix(in srgb, var(--item-color) 40%, transparent);
+ transition: transform 0.2s ease;
+}
+
+.sequence-item:hover {
+ transform: scale(1.05);
+}
+
+.sequence-item-label {
+ color: white;
+ font-weight: 700;
+ font-size: 0.9rem;
+ text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
+}
+
+.sequence-timing-viz {
+ background: rgb(30 41 59 / 0.4);
+ border-radius: 0.75rem;
+ padding: 1rem 1.25rem;
+ margin-bottom: 1rem;
+}
+
+.timing-bars {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.timing-bar-row {
+ display: grid;
+ grid-template-columns: 60px 1fr 60px;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.timing-bar-label {
+ font-size: 0.8rem;
+ color: rgb(148 163 184 / 0.8);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.timing-bar-track {
+ height: 8px;
+ background: rgb(15 23 42 / 0.6);
+ border-radius: 4px;
+ overflow: hidden;
+}
+
+.timing-bar-fill {
+ height: 100%;
+ border-radius: 4px;
+ transition: width 0.3s ease;
+}
+
+.timing-bar-value {
+ font-size: 0.8rem;
+ color: rgb(148 163 184 / 0.9);
+ font-family: 'JetBrains Mono', 'Fira Code', monospace;
+ text-align: right;
+}
+
+.sequence-formula {
+ background: rgb(30 41 59 / 0.4);
+ border-radius: 0.75rem;
+ padding: 1rem 1.25rem;
+ text-align: center;
+}
+
+.formula-code {
+ display: inline-block;
+ background: rgb(15 23 42 / 0.8);
+ padding: 0.5em 1em;
+ border-radius: 6px;
+ font-family: 'JetBrains Mono', 'Fira Code', monospace;
+ font-size: 0.9rem;
+ color: #a5b4fc;
+}
diff --git a/apps/demo/src/web/App.tsx b/apps/demo/src/web/App.tsx
index bf456e8..d338dbf 100644
--- a/apps/demo/src/web/App.tsx
+++ b/apps/demo/src/web/App.tsx
@@ -3,6 +3,7 @@ import { ScrollShowcase } from './components/ScrollShowcase';
import { ResponsiveDemo } from './components/ResponsiveDemo';
import { SelectorConditionDemo } from './components/SelectorConditionDemo';
import { PointerMoveDemo } from './components/PointerMoveDemo';
+import { SequenceDemo } from './components/SequenceDemo';
const heroCopy = [
'Tune triggers, easings, and delays in real time.',
@@ -35,6 +36,7 @@ function App() {
+
diff --git a/apps/demo/src/web/components/SequenceDemo.tsx b/apps/demo/src/web/components/SequenceDemo.tsx
new file mode 100644
index 0000000..f6213dd
--- /dev/null
+++ b/apps/demo/src/web/components/SequenceDemo.tsx
@@ -0,0 +1,259 @@
+import { useState, useMemo, useCallback } from 'react';
+import type { InteractConfig } from '@wix/interact/web';
+import { useInteractInstance } from '../hooks/useInteractInstance';
+
+const createConfig = (offsetEasing: string): InteractConfig => ({
+ effects: {
+ 'seq-effect-0': {
+ key: 'seq-item-1',
+ duration: 600,
+ easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
+ fill: 'both',
+ keyframeEffect: {
+ name: 'seq-entrance-0',
+ keyframes: [
+ { transform: 'translateY(40px) scale(0.8)', opacity: 0 },
+ { transform: 'translateY(0) scale(1)', opacity: 1 },
+ ],
+ },
+ },
+ 'seq-effect-1': {
+ key: 'seq-item-2',
+ duration: 600,
+ easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
+ fill: 'both',
+ keyframeEffect: {
+ name: 'seq-entrance-1',
+ keyframes: [
+ { transform: 'translateY(40px) scale(0.8)', opacity: 0 },
+ { transform: 'translateY(0) scale(1)', opacity: 1 },
+ ],
+ },
+ },
+ 'seq-effect-2': {
+ key: 'seq-item-3',
+ duration: 600,
+ easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
+ fill: 'both',
+ keyframeEffect: {
+ name: 'seq-entrance-2',
+ keyframes: [
+ { transform: 'translateY(40px) scale(0.8)', opacity: 0 },
+ { transform: 'translateY(0) scale(1)', opacity: 1 },
+ ],
+ },
+ },
+ 'seq-effect-3': {
+ key: 'seq-item-4',
+ duration: 600,
+ easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
+ fill: 'both',
+ keyframeEffect: {
+ name: 'seq-entrance-3',
+ keyframes: [
+ { transform: 'translateY(40px) scale(0.8)', opacity: 0 },
+ { transform: 'translateY(0) scale(1)', opacity: 1 },
+ ],
+ },
+ },
+ 'seq-effect-4': {
+ key: 'seq-item-5',
+ duration: 600,
+ easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
+ fill: 'both',
+ keyframeEffect: {
+ name: 'seq-entrance-4',
+ keyframes: [
+ { transform: 'translateY(40px) scale(0.8)', opacity: 0 },
+ { transform: 'translateY(0) scale(1)', opacity: 1 },
+ ],
+ },
+ },
+ 'seq-effect-5': {
+ key: 'seq-item-6',
+ duration: 600,
+ easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
+ fill: 'both',
+ keyframeEffect: {
+ name: 'seq-entrance-5',
+ keyframes: [
+ { transform: 'translateY(40px) scale(0.8)', opacity: 0 },
+ { transform: 'translateY(0) scale(1)', opacity: 1 },
+ ],
+ },
+ },
+ },
+ sequences: {
+ 'entrance-sequence': {
+ sequenceId: 'entrance-sequence',
+ delay: 0,
+ offset: 120,
+ offsetEasing,
+ effects: [
+ { effectId: 'seq-effect-0' },
+ { effectId: 'seq-effect-1' },
+ { effectId: 'seq-effect-2' },
+ { effectId: 'seq-effect-3' },
+ { effectId: 'seq-effect-4' },
+ { effectId: 'seq-effect-5' },
+ ],
+ },
+ },
+ interactions: [
+ {
+ key: 'sequence-trigger',
+ trigger: 'click',
+ params: { type: 'alternate', threshold: 0.5 },
+ sequences: [{ sequenceId: 'entrance-sequence' }],
+ },
+ ],
+});
+
+// Easing presets showcasing different types
+const easingPresets = [
+ { name: 'linear', value: 'linear', description: 'Named easing' },
+ { name: 'quadOut', value: 'quadOut', description: 'Named easing' },
+ { name: 'expoIn', value: 'expoIn', description: 'Named easing' },
+ { name: 'ease-out', value: 'cubic-bezier(0.4, 0, 0.2, 1)', description: 'CSS cubic-bezier' },
+ { name: 'ease-in', value: 'cubic-bezier(0.4, 0, 1, 1)', description: 'CSS cubic-bezier' },
+ {
+ name: 'overshoot',
+ value: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
+ description: 'CSS cubic-bezier (y > 1)',
+ },
+ {
+ name: 'back-in',
+ value: 'cubic-bezier(0.6, -0.28, 0.735, 0.045)',
+ description: 'CSS cubic-bezier (y < 0)',
+ },
+ { name: 'linear ramp', value: 'linear(0, 0.5, 1)', description: 'CSS linear()' },
+ { name: 'fast-then-slow', value: 'linear(0, 0.3 50%, 1)', description: 'CSS linear()' },
+];
+
+const sequenceItems = [
+ { id: 1, label: 'First', color: '#3b82f6' },
+ { id: 2, label: 'Second', color: '#8b5cf6' },
+ { id: 3, label: 'Third', color: '#ec4899' },
+ { id: 4, label: 'Fourth', color: '#f97316' },
+ { id: 5, label: 'Fifth', color: '#22c55e' },
+ { id: 6, label: 'Sixth', color: '#06b6d4' },
+];
+
+export const SequenceDemo = () => {
+ const [triggerKey, setTriggerKey] = useState(0);
+ const [configText, setConfigText] = useState(() =>
+ JSON.stringify(createConfig('quadOut'), null, 2),
+ );
+ const [parseError, setParseError] = useState
(null);
+ const [activePreset, setActivePreset] = useState('quadOut');
+
+ const config = useMemo(() => {
+ try {
+ const parsed = JSON.parse(configText);
+ setParseError(null);
+ return parsed as InteractConfig;
+ } catch (e) {
+ setParseError((e as Error).message);
+ return createConfig('quadOut');
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [configText, triggerKey]); // triggerKey forces re-creation of Interact instance
+
+ useInteractInstance(config);
+
+ const handleApply = () => {
+ setTriggerKey((prev) => prev + 1);
+ };
+
+ const handleReset = () => {
+ setConfigText(JSON.stringify(createConfig('quadOut'), null, 2));
+ setActivePreset('quadOut');
+ setTriggerKey((prev) => prev + 1);
+ };
+
+ const handlePresetClick = useCallback((preset: (typeof easingPresets)[0]) => {
+ setConfigText(JSON.stringify(createConfig(preset.value), null, 2));
+ setActivePreset(preset.value);
+ setTriggerKey((prev) => prev + 1);
+ }, []);
+
+ return (
+
+
+
Sequence Feature
+
Staggered Animations
+
+ Use offsetEasing to control stagger timing. Supports named easings, CSS{' '}
+ cubic-bezier(), CSS linear(), and custom functions.
+
+
+
+
+
Try Different Easing Types
+
+ {easingPresets.map((preset) => (
+
+ ))}
+
+
+
+
+
+
+
InteractConfig (editable JSON)
+ {parseError &&
Parse error: {parseError}}
+
+
+
+
+
+
+
Scroll into view or click Apply & Replay
+
+ {sequenceItems.map((item) => (
+
+
+ {item.label}
+
+
+ ))}
+
+
+
+
+
+
+
+
Offset Formula
+
+ delay + (easing(index / lastIndex) × lastIndex × offset)
+
+
+
+ );
+};
diff --git a/packages/interact/src/core/Interact.ts b/packages/interact/src/core/Interact.ts
index 4e19a6e..6e5004b 100644
--- a/packages/interact/src/core/Interact.ts
+++ b/packages/interact/src/core/Interact.ts
@@ -8,10 +8,13 @@ import {
ViewEnterHandlerModule,
IInteractionController,
IInteractElement,
+ Sequence,
+ SequenceRef,
} from '../types';
import { getInterpolatedKey } from './utilities';
import { generateId } from '../utils';
import TRIGGER_TO_HANDLER_MODULE_MAP from '../handlers';
+import type { Sequence as MotionSequence } from '@wix/motion';
import { registerEffects } from '@wix/motion';
function _convertToKeyTemplate(key: string) {
@@ -34,13 +37,14 @@ export class Interact {
[listContainer: string]: { [interactionId: string]: boolean };
};
controllers: Set;
+ static sequenceCache: Map = new Map();
static forceReducedMotion: boolean = false;
static allowA11yTriggers: boolean = true;
static instances: Interact[] = [];
static controllerCache = new Map();
constructor() {
- this.dataCache = { effects: {}, conditions: {}, interactions: {} };
+ this.dataCache = { effects: {}, sequences: {}, conditions: {}, interactions: {} };
this.addedInteractions = {};
this.mediaQueryListeners = new Map();
this.listInteractionsCache = {};
@@ -90,7 +94,8 @@ export class Interact {
this.addedInteractions = {};
this.listInteractionsCache = {};
this.controllers.clear();
- this.dataCache = { effects: {}, conditions: {}, interactions: {} };
+ Interact.sequenceCache.clear();
+ this.dataCache = { effects: {}, sequences: {}, conditions: {}, interactions: {} };
Interact.instances.splice(Interact.instances.indexOf(this), 1);
}
@@ -166,6 +171,7 @@ export class Interact {
Interact.controllerCache.forEach((controller: IInteractionController) => {
controller.disconnect();
});
+ Interact.sequenceCache.clear();
Interact.instances.length = 0;
Interact.controllerCache.clear();
}
@@ -220,6 +226,74 @@ export class Interact {
let interactionIdCounter = 0;
+/**
+ * Resolves a sequence reference or inline sequence to a full Sequence object.
+ */
+function resolveSequence(
+ seqOrRef: Sequence | SequenceRef,
+ configSequences: Record,
+): Sequence | null {
+ const sequenceId = seqOrRef.sequenceId;
+ if (!('effects' in seqOrRef) || !seqOrRef.effects) {
+ const referencedSequence = configSequences[sequenceId];
+
+ if (!referencedSequence) {
+ console.warn(`Sequence with id "${sequenceId}" not found in config.sequences`);
+ return null;
+ }
+
+ return {
+ ...referencedSequence,
+ delay: seqOrRef.delay ?? referencedSequence.delay,
+ offset: seqOrRef.offset ?? referencedSequence.offset,
+ offsetEasing: seqOrRef.offsetEasing ?? referencedSequence.offsetEasing,
+ };
+ }
+
+ return seqOrRef;
+}
+
+/**
+ * Expands effects from sequences into the effects array with sequence metadata.
+ * Delay calculation is handled by the Sequence class in @wix/motion.
+ */
+function expandSequenceEffects(
+ interaction: Interaction,
+ configSequences: Record,
+): (Effect | EffectRef)[] {
+ const expandedEffects: (Effect | EffectRef)[] = [];
+
+ if (interaction.sequences) {
+ for (const seqOrRef of interaction.sequences) {
+ const sequence = resolveSequence(seqOrRef, configSequences);
+ if (!sequence) continue;
+
+ // Store sequence options to pass to Sequence class
+ const sequenceOptions = {
+ delay: sequence.delay ?? 0,
+ offset: sequence.offset ?? 100,
+ offsetEasing: sequence.offsetEasing,
+ };
+
+ const totalEffects = sequence.effects.length;
+ sequence.effects.forEach((effect, index) => {
+ // Mark effect with sequence metadata (delay calculation deferred to Sequence class)
+ const effectWithMetadata: Effect | EffectRef = {
+ ...effect,
+ _sequenceId: sequence.sequenceId,
+ _sequenceIndex: index,
+ _sequenceTotal: totalEffects,
+ _sequenceOptions: sequenceOptions,
+ } as any;
+
+ expandedEffects.push(effectWithMetadata);
+ });
+ }
+ }
+
+ return expandedEffects;
+}
+
export function getSelector(
d: Interaction | Effect,
{
@@ -249,12 +323,13 @@ export function getSelector(
*/
function parseConfig(config: InteractConfig, useCutsomElement: boolean = false): InteractCache {
const conditions = config.conditions || {};
+ const sequences = config.sequences || {};
const interactions: InteractCache['interactions'] = {};
config.interactions?.forEach((interaction_) => {
const source = interaction_.key;
const interactionIdx = ++interactionIdCounter;
- const { effects: effects_, ...rest } = interaction_;
+ const { effects: effects_, sequences: sequences_, ...rest } = interaction_;
if (!source) {
console.error(`Interaction ${interactionIdx} is missing a key for source element.`);
@@ -270,10 +345,18 @@ function parseConfig(config: InteractConfig, useCutsomElement: boolean = false):
};
}
+ /*
+ * Expand sequence effects and combine with direct effects
+ */
+ const sequenceEffects = expandSequenceEffects(
+ { ...interaction_, sequences: sequences_ } as Interaction,
+ sequences,
+ );
+
/*
* Cache interaction trigger by source element
*/
- const effects = Array.from(effects_);
+ const effects = Array.from([...(effects_ || []), ...sequenceEffects]);
effects.reverse(); // reverse to ensure the first effect is the one that will be applied first
const interaction = { ...rest, effects };
@@ -284,7 +367,7 @@ function parseConfig(config: InteractConfig, useCutsomElement: boolean = false):
const listContainer = interaction.listContainer;
- effects.forEach((effect) => {
+ effects.forEach((effect: (Effect | EffectRef) & { interactionId?: string }) => {
/*
* Target cascade order is the first of:
* -> Config.interactions.effects.effect.key
@@ -350,6 +433,7 @@ function parseConfig(config: InteractConfig, useCutsomElement: boolean = false):
return {
effects: config.effects || {},
+ sequences,
conditions,
interactions,
};
diff --git a/packages/interact/src/core/add.ts b/packages/interact/src/core/add.ts
index 8cbb53f..2fbc735 100644
--- a/packages/interact/src/core/add.ts
+++ b/packages/interact/src/core/add.ts
@@ -9,10 +9,78 @@ import type {
CreateTransitionCSSParams,
IInteractionController,
} from '../types';
+import { isSequenceEffect } from '../types';
import { createTransitionCSS, getMediaQuery, getSelectorCondition } from '../utils';
import { getInterpolatedKey } from './utilities';
import { Interact, getSelector } from './Interact';
import TRIGGER_TO_HANDLER_MODULE_MAP from '../handlers';
+import { getAnimation as motionGetAnimation, Sequence as MotionSequence } from '@wix/motion';
+import type { AnimationOptions, TriggerVariant } from '@wix/motion';
+import { effectToAnimationOptions } from '../handlers/utilities';
+
+export function getAnimation(
+ target: HTMLElement | string | null,
+ animationOptions: AnimationOptions & { _sequenceId?: string },
+ trigger?: Partial & { element?: HTMLElement },
+ reducedMotion: boolean = false,
+) {
+ const sequenceId = animationOptions._sequenceId;
+ if (sequenceId) {
+ return Interact.sequenceCache.get(sequenceId) ?? null;
+ }
+ return motionGetAnimation(target, animationOptions, trigger, reducedMotion);
+}
+
+function buildSequences(
+ interactionsToApply: InteractionsToApply,
+ sequenceCache: Map,
+ reducedMotion: boolean,
+) {
+ const sequenceGroups = new Map<
+ string,
+ {
+ configs: Array<{
+ target: HTMLElement;
+ animationOptions: AnimationOptions;
+ index: number;
+ }>;
+ sequenceOptions: {
+ delay?: number;
+ offset?: number;
+ offsetEasing?: string | ((t: number) => number);
+ };
+ }
+ >();
+
+ for (const [, , effect, , targetElements] of interactionsToApply) {
+ if (!isSequenceEffect(effect)) continue;
+
+ const sequenceId = effect._sequenceId;
+ if (!sequenceGroups.has(sequenceId)) {
+ sequenceGroups.set(sequenceId, {
+ configs: [],
+ sequenceOptions: effect._sequenceOptions || {},
+ });
+ }
+ const group = sequenceGroups.get(sequenceId)!;
+ const targets = Array.isArray(targetElements) ? targetElements : [targetElements];
+ for (const target of targets) {
+ group.configs.push({
+ target,
+ animationOptions: effectToAnimationOptions(effect) as AnimationOptions,
+ index: effect._sequenceIndex,
+ });
+ }
+ }
+
+ for (const [sequenceId, { configs, sequenceOptions }] of sequenceGroups) {
+ configs.sort((a, b) => a.index - b.index);
+ const sequence = MotionSequence.build(configs, sequenceOptions, reducedMotion);
+ if (sequence) {
+ sequenceCache.set(sequenceId, sequence);
+ }
+ }
+}
type InteractionsToApply = Array<
[
@@ -96,8 +164,8 @@ function _applyInteraction(
effect: Effect,
sourceElements: HTMLElement | HTMLElement[],
targetElements: HTMLElement | HTMLElement[],
- selectorCondition?: string,
- useFirstChild?: boolean,
+ selectorCondition: string | undefined,
+ useFirstChild: boolean,
) {
const isSourceArray = Array.isArray(sourceElements);
const isTargetArray = Array.isArray(targetElements);
@@ -147,7 +215,7 @@ function _addInteraction(
const interactionsToApply: InteractionsToApply = [];
- interaction.effects.forEach((effect) => {
+ (interaction.effects || []).forEach((effect) => {
const effectId = (effect as EffectRef).effectId;
const effectOptions = {
@@ -234,7 +302,12 @@ function _addInteraction(
});
// apply the effects in reverse to return to the order specified by the user to ensure order of composition is as defined
- interactionsToApply.reverse().forEach((interaction) => {
+ interactionsToApply.reverse();
+
+ // Build MotionSequences eagerly before calling handlers
+ buildSequences(interactionsToApply, Interact.sequenceCache, Interact.forceReducedMotion);
+
+ interactionsToApply.forEach((interaction) => {
_applyInteraction(...interaction);
});
}
diff --git a/packages/interact/src/handlers/animationEnd.ts b/packages/interact/src/handlers/animationEnd.ts
index 9d50d2d..cd477ed 100644
--- a/packages/interact/src/handlers/animationEnd.ts
+++ b/packages/interact/src/handlers/animationEnd.ts
@@ -1,11 +1,11 @@
import type { AnimationGroup } from '@wix/motion';
-import { getAnimation } from '@wix/motion';
import type { AnimationEndParams, TimeEffect, HandlerObjectMap, InteractOptions } from '../types';
import {
effectToAnimationOptions,
addHandlerToMap,
removeElementFromHandlerMap,
} from './utilities';
+import { getAnimation } from '../core/add';
const handlerMap = new WeakMap() as HandlerObjectMap;
diff --git a/packages/interact/src/handlers/click.ts b/packages/interact/src/handlers/click.ts
index 40c0dfb..41e3561 100644
--- a/packages/interact/src/handlers/click.ts
+++ b/packages/interact/src/handlers/click.ts
@@ -1,4 +1,3 @@
-import { getAnimation } from '@wix/motion';
import type { AnimationGroup } from '@wix/motion';
import type {
TimeEffect,
@@ -10,11 +9,13 @@ import type {
IInteractionController,
InteractOptions,
} from '../types';
+import { isSequenceEffect } from '../types';
import {
effectToAnimationOptions,
addHandlerToMap,
removeElementFromHandlerMap,
} from './utilities';
+import { getAnimation } from '../core/add';
import fastdom from 'fastdom';
const handlerMap = new WeakMap() as HandlerObjectMap;
@@ -26,6 +27,12 @@ function createTimeEffectHandler(
reducedMotion: boolean = false,
selectorCondition?: string,
) {
+ // For sequence effects, only the first effect (index 0) controls playback
+ if (isSequenceEffect(effect) && effect._sequenceIndex !== 0) {
+ // Non-leader sequence effects don't need handlers - the leader controls the Sequence
+ return () => {};
+ }
+
const animation = getAnimation(
element,
effectToAnimationOptions(effect),
diff --git a/packages/interact/src/handlers/hover.ts b/packages/interact/src/handlers/hover.ts
index f7eec11..f1bda37 100644
--- a/packages/interact/src/handlers/hover.ts
+++ b/packages/interact/src/handlers/hover.ts
@@ -1,5 +1,4 @@
import type { AnimationGroup } from '@wix/motion';
-import { getAnimation } from '@wix/motion';
import type {
TimeEffect,
TransitionEffect,
@@ -10,11 +9,13 @@ import type {
IInteractionController,
InteractOptions,
} from '../types';
+import { isSequenceEffect } from '../types';
import {
effectToAnimationOptions,
addHandlerToMap,
removeElementFromHandlerMap,
} from './utilities';
+import { getAnimation } from '../core/add';
import fastdom from 'fastdom';
const handlerMap = new WeakMap() as HandlerObjectMap;
@@ -26,6 +27,12 @@ function createTimeEffectHandler(
reducedMotion: boolean = false,
selectorCondition?: string,
) {
+ // For sequence effects, only the first effect (index 0) controls playback
+ if (isSequenceEffect(effect) && effect._sequenceIndex !== 0) {
+ // Non-leader sequence effects don't need handlers - the leader controls the Sequence
+ return () => {};
+ }
+
const animation = getAnimation(
element,
effectToAnimationOptions(effect),
diff --git a/packages/interact/src/handlers/viewEnter.ts b/packages/interact/src/handlers/viewEnter.ts
index 653fde5..b93c06c 100644
--- a/packages/interact/src/handlers/viewEnter.ts
+++ b/packages/interact/src/handlers/viewEnter.ts
@@ -1,11 +1,12 @@
import type { AnimationGroup } from '@wix/motion';
-import { getAnimation } from '@wix/motion';
import type { TimeEffect, HandlerObjectMap, ViewEnterParams, InteractOptions } from '../types';
+import { isSequenceEffect } from '../types';
import {
effectToAnimationOptions,
addHandlerToMap,
removeElementFromHandlerMap,
} from './utilities';
+import { getAnimation } from '../core/add';
import fastdom from 'fastdom';
const SAFE_OBSERVER_CONFIG: IntersectionObserverInit = {
@@ -139,6 +140,12 @@ function addViewEnterHandler(
options: ViewEnterParams = {},
{ reducedMotion, selectorCondition }: InteractOptions = {},
) {
+ // For sequence effects, only the first effect (index 0) controls playback
+ if (isSequenceEffect(effect) && effect._sequenceIndex !== 0) {
+ // Non-leader sequence effects don't need handlers - the leader controls the Sequence
+ return;
+ }
+
const mergedOptions = { ...viewEnterOptions, ...options };
const type = mergedOptions.type || 'once';
const animation = getAnimation(
diff --git a/packages/interact/src/types.ts b/packages/interact/src/types.ts
index 7bffa72..3cfc91b 100644
--- a/packages/interact/src/types.ts
+++ b/packages/interact/src/types.ts
@@ -147,6 +147,21 @@ export type EffectRef = EffectBase & { effectId: string };
export type Effect = EffectBase & (TimeEffect | ScrubEffect | TransitionEffect);
+export type Sequence = {
+ sequenceId: string;
+ delay?: number;
+ offset?: number;
+ offsetEasing?: string | ((t: number) => number);
+ effects: (Effect | EffectRef)[];
+};
+
+export type SequenceRef = {
+ sequenceId: string;
+ delay?: number;
+ offset?: number;
+ offsetEasing?: string | ((t: number) => number);
+};
+
export type Condition = {
type: 'media' | 'container' | 'selector';
predicate?: string;
@@ -163,11 +178,13 @@ export type InteractionTrigger = {
};
export type Interaction = InteractionTrigger & {
- effects: ((Effect | EffectRef) & { interactionId?: string })[];
+ effects?: ((Effect | EffectRef) & { interactionId?: string })[];
+ sequences?: (Sequence | SequenceRef)[];
};
export type InteractConfig = {
effects: Record;
+ sequences?: Record;
conditions?: Record;
interactions: Interaction[];
};
@@ -224,6 +241,22 @@ export type InteractionParamsTypes = {
interest: StateParams | PointerTriggerParams;
};
+export type SequenceEffect = TimeEffect &
+ EffectBase & {
+ _sequenceId: string;
+ _sequenceIndex: number;
+ _sequenceTotal: number;
+ _sequenceOptions?: {
+ delay?: number;
+ offset?: number;
+ offsetEasing?: string | ((t: number) => number);
+ };
+ };
+
+export function isSequenceEffect(effect: Effect): effect is SequenceEffect {
+ return '_sequenceId' in effect && 'duration' in effect;
+}
+
export type InteractOptions = {
reducedMotion?: boolean;
targetController?: IInteractionController;
@@ -264,6 +297,9 @@ export type InteractCache = {
effects: {
[effectId: string]: Effect;
};
+ sequences: {
+ [sequenceId: string]: Sequence;
+ };
conditions: {
[conditionId: string]: Condition;
};
diff --git a/packages/interact/test/mini.spec.ts b/packages/interact/test/mini.spec.ts
index 62a96fa..5fb7e80 100644
--- a/packages/interact/test/mini.spec.ts
+++ b/packages/interact/test/mini.spec.ts
@@ -29,6 +29,7 @@ vi.mock('@wix/motion', () => {
reducedMotion,
});
}),
+ Sequence: vi.fn(),
registerEffects: vi.fn(),
};
diff --git a/packages/interact/test/react.spec.tsx b/packages/interact/test/react.spec.tsx
index 49fede9..e04b977 100644
--- a/packages/interact/test/react.spec.tsx
+++ b/packages/interact/test/react.spec.tsx
@@ -28,6 +28,7 @@ vi.mock('@wix/motion', () => {
reducedMotion,
});
}),
+ Sequence: vi.fn(),
registerEffects: vi.fn(),
};
diff --git a/packages/interact/test/viewEnter.spec.ts b/packages/interact/test/viewEnter.spec.ts
index 388a95a..886600c 100644
--- a/packages/interact/test/viewEnter.spec.ts
+++ b/packages/interact/test/viewEnter.spec.ts
@@ -1,21 +1,30 @@
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest';
+const mockAnimation = {
+ play: vi.fn(),
+ cancel: vi.fn(),
+ onFinish: vi.fn(),
+ pause: vi.fn(),
+ reverse: vi.fn(),
+ progress: vi.fn(),
+ persist: vi.fn(),
+ isCSS: false,
+ playState: 'idle',
+ ready: Promise.resolve(),
+};
+
+const mockGetAnimation = vi.fn().mockReturnValue(mockAnimation);
+
vi.mock('@wix/motion', () => ({
- getAnimation: vi.fn().mockReturnValue({
- play: vi.fn(),
- cancel: vi.fn(),
- onFinish: vi.fn(),
- pause: vi.fn(),
- reverse: vi.fn(),
- progress: vi.fn(),
- persist: vi.fn(),
- isCSS: false,
- playState: 'idle',
- ready: Promise.resolve(),
- }),
+ getAnimation: vi.fn(),
+ Sequence: vi.fn(),
registerEffects: vi.fn(),
}));
+vi.mock('../src/core/add', () => ({
+ getAnimation: (...args: any[]) => mockGetAnimation(...args),
+}));
+
vi.mock('fastdom', () => ({
default: {
measure: vi.fn((cb) => cb()),
@@ -52,6 +61,7 @@ describe('viewEnter handler', () => {
document.body.appendChild(target);
vi.clearAllMocks();
+ mockGetAnimation.mockReturnValue(mockAnimation);
observeSpy = vi.fn();
unobserveSpy = vi.fn();
@@ -234,9 +244,6 @@ describe('viewEnter handler', () => {
describe('Alternate type', () => {
it('should play animation on first entry', async () => {
- const { getAnimation } = await import('@wix/motion');
- const mockAnimation = (getAnimation as any)();
-
viewEnterHandler.add(
element,
target,
@@ -252,9 +259,6 @@ describe('viewEnter handler', () => {
});
it('should reverse animation on exit', async () => {
- const { getAnimation } = await import('@wix/motion');
- const mockAnimation = (getAnimation as any)();
-
viewEnterHandler.add(
element,
target,
@@ -277,9 +281,6 @@ describe('viewEnter handler', () => {
});
it('should reverse animation on subsequent re-entry', async () => {
- const { getAnimation } = await import('@wix/motion');
- const mockAnimation = (getAnimation as any)();
-
viewEnterHandler.add(
element,
target,
@@ -320,9 +321,6 @@ describe('viewEnter handler', () => {
});
it('should persist the animation', async () => {
- const { getAnimation } = await import('@wix/motion');
- const mockAnimation = (getAnimation as any)();
-
viewEnterHandler.add(
element,
target,
@@ -337,9 +335,6 @@ describe('viewEnter handler', () => {
describe('Repeat type', () => {
it('should play animation from 0 on entry', async () => {
- const { getAnimation } = await import('@wix/motion');
- const mockAnimation = (getAnimation as any)();
-
viewEnterHandler.add(
element,
target,
@@ -356,9 +351,6 @@ describe('viewEnter handler', () => {
});
it('should pause and reset progress to 0 on exit', async () => {
- const { getAnimation } = await import('@wix/motion');
- const mockAnimation = (getAnimation as any)();
-
viewEnterHandler.add(
element,
target,
@@ -383,9 +375,6 @@ describe('viewEnter handler', () => {
});
it('should play from 0 on subsequent re-entry', async () => {
- const { getAnimation } = await import('@wix/motion');
- const mockAnimation = (getAnimation as any)();
-
viewEnterHandler.add(
element,
target,
@@ -428,9 +417,6 @@ describe('viewEnter handler', () => {
});
it('should persist the animation', async () => {
- const { getAnimation } = await import('@wix/motion');
- const mockAnimation = (getAnimation as any)();
-
viewEnterHandler.add(
element,
target,
@@ -445,9 +431,6 @@ describe('viewEnter handler', () => {
describe('State type', () => {
it('should play animation on first entry', async () => {
- const { getAnimation } = await import('@wix/motion');
- const mockAnimation = (getAnimation as any)();
-
viewEnterHandler.add(
element,
target,
@@ -463,9 +446,6 @@ describe('viewEnter handler', () => {
});
it('should pause animation on exit', async () => {
- const { getAnimation } = await import('@wix/motion');
- const mockAnimation = (getAnimation as any)();
-
viewEnterHandler.add(
element,
target,
@@ -488,9 +468,6 @@ describe('viewEnter handler', () => {
});
it('should resume animation on subsequent re-entry', async () => {
- const { getAnimation } = await import('@wix/motion');
- const mockAnimation = (getAnimation as any)();
-
viewEnterHandler.add(
element,
target,
@@ -516,9 +493,6 @@ describe('viewEnter handler', () => {
});
it('should NOT reset progress on re-entry', async () => {
- const { getAnimation } = await import('@wix/motion');
- const mockAnimation = (getAnimation as any)();
-
viewEnterHandler.add(
element,
target,
@@ -560,9 +534,6 @@ describe('viewEnter handler', () => {
});
it('should persist the animation', async () => {
- const { getAnimation } = await import('@wix/motion');
- const mockAnimation = (getAnimation as any)();
-
viewEnterHandler.add(
element,
target,
@@ -577,9 +548,7 @@ describe('viewEnter handler', () => {
describe('Null animation handling', () => {
it('should not create IntersectionObserver when animation is null', async () => {
- const { getAnimation } = await import('@wix/motion');
- (getAnimation as any).mockReturnValueOnce(null);
-
+ mockGetAnimation.mockReturnValueOnce(null);
viewEnterHandler.add(
element,
target,
diff --git a/packages/interact/test/web.spec.ts b/packages/interact/test/web.spec.ts
index b5730af..725d58f 100644
--- a/packages/interact/test/web.spec.ts
+++ b/packages/interact/test/web.spec.ts
@@ -29,6 +29,7 @@ vi.mock('@wix/motion', () => {
reducedMotion,
});
}),
+ Sequence: vi.fn(),
registerEffects: vi.fn(),
};
diff --git a/packages/motion/src/AnimationGroup.ts b/packages/motion/src/AnimationGroup.ts
index eb2750e..8ddd788 100644
--- a/packages/motion/src/AnimationGroup.ts
+++ b/packages/motion/src/AnimationGroup.ts
@@ -22,6 +22,16 @@ export class AnimationGroup {
this.isCSS = animations[0] instanceof CSSAnimation;
}
+ addGroupDelay(delay: number, endDelay?: number) {
+ for (const animation of this.animations) {
+ const timing = animation.effect?.getTiming();
+ if (timing) {
+ const existingDelay = (timing.delay as number) || 0;
+ animation.effect?.updateTiming({ delay: existingDelay + delay, endDelay });
+ }
+ }
+ }
+
getProgress() {
return this.animations[0]?.effect?.getComputedTiming().progress || 0;
}
diff --git a/packages/motion/src/Sequence.ts b/packages/motion/src/Sequence.ts
new file mode 100644
index 0000000..e992577
--- /dev/null
+++ b/packages/motion/src/Sequence.ts
@@ -0,0 +1,75 @@
+import { AnimationGroup } from './AnimationGroup';
+import type { AnimationOptions, SequenceOptions } from './types';
+import { calculateOffsets, resolveEasingFunction } from './utils';
+import { getAnimation } from './motion';
+
+export class Sequence extends AnimationGroup {
+ animationGroups: AnimationGroup[];
+ sequenceDelay: number;
+ offset: number;
+ offsetEasing: (t: number) => number;
+ private _calculatedOffsets: number[];
+
+ constructor(animationGroups: AnimationGroup[], options?: SequenceOptions) {
+ const allAnimations = animationGroups.flatMap((group) => group.animations);
+ super(allAnimations, options);
+
+ this.animationGroups = animationGroups;
+ this.sequenceDelay = options?.delay ?? 0;
+ this.offset = options?.offset ?? 100;
+ this.offsetEasing = resolveEasingFunction(options?.offsetEasing);
+ this._calculatedOffsets = calculateOffsets(
+ animationGroups.length,
+ this.offset,
+ this.offsetEasing,
+ );
+ this._applyDelays();
+ }
+
+ private _applyDelays(): void {
+ const minOffset = Math.min(...this._calculatedOffsets);
+ const maxOffset = Math.max(...this._calculatedOffsets);
+ const totalSpan = maxOffset - minOffset;
+
+ this.animationGroups.forEach((group, index) => {
+ // Normalize offset to be non-negative
+ const normalizedOffset =
+ minOffset < 0 ? this._calculatedOffsets[index] - minOffset : this._calculatedOffsets[index];
+ const groupDelay = this.sequenceDelay + normalizedOffset;
+ const endDelay = totalSpan - normalizedOffset;
+ group.addGroupDelay(groupDelay, endDelay);
+ });
+ }
+
+ getOffsetAt(index: number): number {
+ return this._calculatedOffsets[index] ?? 0;
+ }
+
+ getOffsets(): number[] {
+ return [...this._calculatedOffsets];
+ }
+
+ recalculateOffsets(): void {
+ this._calculatedOffsets = calculateOffsets(
+ this.animationGroups.length,
+ this.offset,
+ this.offsetEasing,
+ );
+ }
+
+ static build(
+ configs: Array<{ target: HTMLElement | string | null; animationOptions: AnimationOptions }>,
+ sequenceOptions?: SequenceOptions,
+ reducedMotion: boolean = false,
+ ): Sequence | null {
+ const groups = configs
+ .map((cfg) => getAnimation(cfg.target, cfg.animationOptions, undefined, reducedMotion))
+ .filter((a): a is AnimationGroup => a instanceof AnimationGroup);
+ return groups.length ? new Sequence(groups, sequenceOptions) : null;
+ }
+
+ // Note: play(), pause(), reverse(), cancel(), setPlaybackRate(), onFinish(),
+ // finished, and playState are inherited from AnimationGroup.
+ // Since we pass all flattened animations to super(), the parent's
+ // implementations work correctly on the same Animation objects.
+}
diff --git a/packages/motion/src/index.ts b/packages/motion/src/index.ts
index 29ac511..26de44a 100644
--- a/packages/motion/src/index.ts
+++ b/packages/motion/src/index.ts
@@ -3,3 +3,5 @@ export * from './types';
export { registerEffects } from './api/registry';
export * from './utils';
export * from './easings';
+export { AnimationGroup } from './AnimationGroup';
+export { Sequence } from './Sequence';
diff --git a/packages/motion/src/types.ts b/packages/motion/src/types.ts
index a9ec3c6..6e5f592 100644
--- a/packages/motion/src/types.ts
+++ b/packages/motion/src/types.ts
@@ -217,6 +217,30 @@ export type AnimationGroupOptions = AnimationOptions & {
measured?: Promise;
};
+/**
+ * Options for creating a Sequence of AnimationGroups with staggered delays.
+ */
+export type SequenceOptions = AnimationGroupOptions & {
+ /**
+ * Fixed delay in milliseconds before the entire sequence starts.
+ * @default 0
+ */
+ delay?: number;
+ /**
+ * Base offset in milliseconds between each effect in the sequence.
+ * The actual delay for each effect is calculated using this value
+ * multiplied by the result of the offsetEasing function.
+ * @default 100
+ */
+ offset?: number;
+ /**
+ * Easing function to apply to offset calculation.
+ * Can be a JS function, a named easing from @wix/motion, or a CSS easing value.
+ * @default 'linear'
+ */
+ offsetEasing?: string | ((t: number) => number);
+};
+
export type Shape = 'ellipse' | 'circle' | 'rectangle' | 'diamond' | 'window';
export interface ScrubScrollScene {
diff --git a/packages/motion/src/utils.ts b/packages/motion/src/utils.ts
index 78e3dbb..b87d462 100644
--- a/packages/motion/src/utils.ts
+++ b/packages/motion/src/utils.ts
@@ -1,4 +1,5 @@
import { cssEasings, jsEasings } from './easings';
+
export function getCssUnits(unit: 'percentage' | string) {
return unit === 'percentage' ? '%' : unit || 'px';
}
@@ -12,3 +13,156 @@ export function getJsEasing(
): ((t: number) => number) | undefined {
return easing ? jsEasings[easing as keyof typeof jsEasings] : undefined;
}
+
+export function createCubicBezier(
+ x1: number,
+ y1: number,
+ x2: number,
+ y2: number,
+): (t: number) => number {
+ const cx = 3 * x1;
+ const bx = 3 * (x2 - x1) - cx;
+ const ax = 1 - cx - bx;
+
+ const cy = 3 * y1;
+ const by = 3 * (y2 - y1) - cy;
+ const ay = 1 - cy - by;
+
+ const sampleCurveX = (t: number) => ((ax * t + bx) * t + cx) * t;
+ const sampleCurveY = (t: number) => ((ay * t + by) * t + cy) * t;
+ const sampleCurveDerivativeX = (t: number) => (3 * ax * t + 2 * bx) * t + cx;
+
+ const solveCurveX = (x: number): number => {
+ let t = x;
+
+ for (let i = 0; i < 8; i++) {
+ const xError = sampleCurveX(t) - x;
+ if (Math.abs(xError) < 1e-6) return t;
+ const dx = sampleCurveDerivativeX(t);
+ if (Math.abs(dx) < 1e-6) break;
+ t -= xError / dx;
+ }
+
+ let t0 = 0;
+ let t1 = 1;
+ t = x;
+
+ while (t0 < t1) {
+ const xMid = sampleCurveX(t);
+ if (Math.abs(xMid - x) < 1e-6) return t;
+ if (x > xMid) t0 = t;
+ else t1 = t;
+ t = (t1 - t0) / 2 + t0;
+ }
+
+ return t;
+ };
+
+ return (x: number): number => {
+ if (x <= 0) return 0;
+ if (x >= 1) return 1;
+ return sampleCurveY(solveCurveX(x));
+ };
+}
+
+export function parseCubicBezier(str: string): ((t: number) => number) | null {
+ const match = str.match(
+ /cubic-bezier\s*\(\s*([^,]+)\s*,\s*([^,]+)\s*,\s*([^,]+)\s*,\s*([^)]+)\s*\)/i,
+ );
+ if (!match) return null;
+
+ const [, x1, y1, x2, y2] = match.map((v, i) => (i === 0 ? v : parseFloat(v)));
+ if ([x1, y1, x2, y2].some((v) => typeof v !== 'number' || isNaN(v as number))) return null;
+
+ return createCubicBezier(x1 as number, y1 as number, x2 as number, y2 as number);
+}
+
+type LinearStop = { value: number; position: number };
+
+export function createLinear(stops: LinearStop[]): (t: number) => number {
+ if (stops.length === 0) return () => 0;
+ if (stops.length === 1) return () => stops[0].value;
+
+ // Sort stops by position
+ const sorted = [...stops].sort((a, b) => a.position - b.position);
+
+ return (t: number): number => {
+ if (t <= 0) return sorted[0].value;
+ if (t >= 1) return sorted[sorted.length - 1].value;
+
+ // Find the two stops to interpolate between
+ for (let i = 0; i < sorted.length - 1; i++) {
+ const current = sorted[i];
+ const next = sorted[i + 1];
+
+ if (t >= current.position && t <= next.position) {
+ const range = next.position - current.position;
+ if (range === 0) return current.value;
+ const localT = (t - current.position) / range;
+ return current.value + (next.value - current.value) * localT;
+ }
+ }
+
+ return sorted[sorted.length - 1].value;
+ };
+}
+
+export function parseLinear(str: string): ((t: number) => number) | null {
+ const match = str.match(/^linear\s*\(\s*(.+)\s*\)$/i);
+ if (!match) return null;
+
+ const content = match[1];
+ const parts = content.split(',').map((s) => s.trim());
+ if (parts.length < 2) return null;
+
+ const stops: LinearStop[] = [];
+
+ for (let i = 0; i < parts.length; i++) {
+ const part = parts[i].trim();
+ // Match value with optional percentage: "0.5" or "0.5 75%"
+ const valueMatch = part.match(/^(-?[\d.]+)(?:\s+(-?[\d.]+)%)?$/);
+ if (!valueMatch) return null;
+
+ const value = parseFloat(valueMatch[1]);
+ if (isNaN(value)) return null;
+
+ let position: number;
+ if (valueMatch[2] !== undefined) {
+ // Explicit percentage provided
+ position = parseFloat(valueMatch[2]) / 100;
+ } else {
+ // Auto-distribute: first is 0%, last is 100%, others evenly spaced
+ position = i / (parts.length - 1);
+ }
+
+ stops.push({ value, position });
+ }
+
+ return createLinear(stops);
+}
+
+export function resolveEasingFunction(
+ easing: string | ((t: number) => number) | undefined,
+): (t: number) => number {
+ if (typeof easing === 'function') return easing;
+
+ if (typeof easing === 'string') {
+ if (easing in jsEasings) return jsEasings[easing as keyof typeof jsEasings];
+ const bezierFn = parseCubicBezier(easing);
+ if (bezierFn) return bezierFn;
+ const linearFn = parseLinear(easing);
+ if (linearFn) return linearFn;
+ }
+
+ return jsEasings.linear;
+}
+
+export function calculateOffsets(
+ count: number,
+ offset: number,
+ easingFn: (t: number) => number,
+): number[] {
+ if (count <= 1) return [0];
+ const last = count - 1;
+ return Array.from({ length: count }, (_, i) => (easingFn(i / last) * last * offset) | 0);
+}
diff --git a/packages/motion/test/Sequence.spec.ts b/packages/motion/test/Sequence.spec.ts
new file mode 100644
index 0000000..8ede4e8
--- /dev/null
+++ b/packages/motion/test/Sequence.spec.ts
@@ -0,0 +1,807 @@
+import { describe, expect, test, vi, beforeEach, beforeAll } from 'vitest';
+import { Sequence } from '../src/Sequence';
+import {
+ calculateOffsets,
+ parseCubicBezier,
+ createCubicBezier,
+ parseLinear,
+ createLinear,
+} from '../src/utils';
+import { AnimationGroup } from '../src/AnimationGroup';
+import { linear, quadIn, sineOut, cubicIn, expoIn, quadOut, cubicOut } from '../src/easings';
+
+// Stub CSSAnimation for Node.js environment
+beforeAll(() => {
+ if (typeof globalThis.CSSAnimation === 'undefined') {
+ (globalThis as any).CSSAnimation = class CSSAnimation {};
+ }
+});
+
+describe('Sequence', () => {
+ describe('calculateOffsets()', () => {
+ test('should return [0] for count <= 1', () => {
+ expect(calculateOffsets(0, 100, linear)).toEqual([0]);
+ expect(calculateOffsets(1, 100, linear)).toEqual([0]);
+ });
+
+ test('should calculate linear offsets correctly', () => {
+ // 5 items with 200ms offset, linear easing
+ // indices: [0, 1, 2, 3, 4], last = 4
+ // linear(0/4) * 4 * 200 = 0 * 800 = 0
+ // linear(1/4) * 4 * 200 = 0.25 * 800 = 200
+ // linear(2/4) * 4 * 200 = 0.5 * 800 = 400
+ // linear(3/4) * 4 * 200 = 0.75 * 800 = 600
+ // linear(4/4) * 4 * 200 = 1 * 800 = 800
+ const result = calculateOffsets(5, 200, linear);
+ expect(result).toEqual([0, 200, 400, 600, 800]);
+ });
+
+ test('should calculate quadIn offsets correctly', () => {
+ // 5 items with 200ms offset, quadIn easing (t^2)
+ // indices: [0, 1, 2, 3, 4], last = 4
+ // quadIn(0/4) * 4 * 200 = 0^2 * 800 = 0
+ // quadIn(1/4) * 4 * 200 = 0.0625 * 800 = 50
+ // quadIn(2/4) * 4 * 200 = 0.25 * 800 = 200
+ // quadIn(3/4) * 4 * 200 = 0.5625 * 800 = 450
+ // quadIn(4/4) * 4 * 200 = 1 * 800 = 800
+ const result = calculateOffsets(5, 200, quadIn);
+ expect(result).toEqual([0, 50, 200, 450, 800]);
+ });
+
+ test('should calculate sineOut offsets correctly', () => {
+ // 5 items with 200ms offset, sineOut easing
+ const result = calculateOffsets(5, 200, sineOut);
+ // sineOut(0) = 0
+ // sineOut(0.25) ≈ 0.3827
+ // sineOut(0.5) ≈ 0.7071
+ // sineOut(0.75) ≈ 0.9239
+ // sineOut(1) = 1
+ expect(result[0]).toBe(0);
+ expect(result[1]).toBeGreaterThan(200); // faster start
+ expect(result[2]).toBeGreaterThan(400); // much faster
+ expect(result[3]).toBeGreaterThan(600); // slowing down
+ expect(result[4]).toBe(800);
+ });
+
+ test('should calculate cubicIn offsets correctly', () => {
+ // 5 items with 100ms offset, cubicIn easing (t^3)
+ const result = calculateOffsets(5, 100, cubicIn);
+ expect(result[0]).toBe(0);
+ expect(result[1]).toBeLessThan(100); // slow start
+ expect(result[2]).toBeLessThan(200); // still slow
+ expect(result[3]).toBeLessThan(300); // accelerating
+ expect(result[4]).toBe(400); // last = 4, so 4 * 100 = 400
+ });
+
+ test('should handle different offset values', () => {
+ const result50 = calculateOffsets(3, 50, linear);
+ expect(result50).toEqual([0, 50, 100]);
+
+ const result150 = calculateOffsets(3, 150, linear);
+ expect(result150).toEqual([0, 150, 300]);
+
+ const result1000 = calculateOffsets(3, 1000, linear);
+ expect(result1000).toEqual([0, 1000, 2000]);
+ });
+
+ test('should floor results using bitwise OR', () => {
+ // Test that fractional results are floored
+ const result = calculateOffsets(3, 100, (t) => t * 0.333);
+ // (0.333 * 0) * 2 * 100 = 0
+ // (0.333 * 0.5) * 2 * 100 = 33.3 → 33
+ // (0.333 * 1) * 2 * 100 = 66.6 → 66
+ expect(result[0]).toBe(0);
+ expect(result[1]).toBe(33);
+ expect(result[2]).toBe(66);
+ });
+ });
+
+ describe('Sequence class', () => {
+ let mockAnimations: any[];
+ let mockAnimationGroups: AnimationGroup[];
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ // Create mock animations with effects
+ mockAnimations = [
+ {
+ id: 'anim-1',
+ play: vi.fn(),
+ pause: vi.fn(),
+ reverse: vi.fn(),
+ cancel: vi.fn(),
+ ready: Promise.resolve(),
+ finished: Promise.resolve(),
+ playState: 'idle',
+ playbackRate: 1,
+ effect: {
+ getTiming: vi.fn(() => ({ delay: 0, duration: 1000, iterations: 1 })),
+ updateTiming: vi.fn(),
+ getComputedTiming: vi.fn(() => ({ progress: 0 })),
+ },
+ },
+ {
+ id: 'anim-2',
+ play: vi.fn(),
+ pause: vi.fn(),
+ reverse: vi.fn(),
+ cancel: vi.fn(),
+ ready: Promise.resolve(),
+ finished: Promise.resolve(),
+ playState: 'idle',
+ playbackRate: 1,
+ effect: {
+ getTiming: vi.fn(() => ({ delay: 100, duration: 800, iterations: 1 })),
+ updateTiming: vi.fn(),
+ getComputedTiming: vi.fn(() => ({ progress: 0.5 })),
+ },
+ },
+ {
+ id: 'anim-3',
+ play: vi.fn(),
+ pause: vi.fn(),
+ reverse: vi.fn(),
+ cancel: vi.fn(),
+ ready: Promise.resolve(),
+ finished: Promise.resolve(),
+ playState: 'running',
+ playbackRate: 1,
+ effect: {
+ getTiming: vi.fn(() => ({ delay: 50, duration: 1200, iterations: 1 })),
+ updateTiming: vi.fn(),
+ getComputedTiming: vi.fn(() => ({ progress: 0.25 })),
+ },
+ },
+ ];
+
+ // Create real AnimationGroup instances with mock animations
+ mockAnimationGroups = [
+ new AnimationGroup([mockAnimations[0]] as unknown as Animation[]),
+ new AnimationGroup([mockAnimations[1]] as unknown as Animation[]),
+ new AnimationGroup([mockAnimations[2]] as unknown as Animation[]),
+ ];
+ });
+
+ test('should create a Sequence with default options', () => {
+ const sequence = new Sequence(mockAnimationGroups);
+
+ expect(sequence.animationGroups).toBe(mockAnimationGroups);
+ expect(sequence.sequenceDelay).toBe(0);
+ expect(sequence.offset).toBe(100);
+ expect(typeof sequence.offsetEasing).toBe('function');
+ });
+
+ test('should create a Sequence with custom delay', () => {
+ const sequence = new Sequence(mockAnimationGroups, { delay: 500 });
+
+ expect(sequence.sequenceDelay).toBe(500);
+ });
+
+ test('should create a Sequence with custom offset', () => {
+ const sequence = new Sequence(mockAnimationGroups, { offset: 200 });
+
+ expect(sequence.offset).toBe(200);
+ });
+
+ test('should create a Sequence with string easing name', () => {
+ const sequence = new Sequence(mockAnimationGroups, { offsetEasing: 'quadIn' });
+
+ // Test that it resolves to the quadIn function
+ expect(sequence.offsetEasing(0.5)).toBeCloseTo(quadIn(0.5));
+ });
+
+ test('should create a Sequence with custom easing function', () => {
+ const customEasing = (t: number) => t * t * t;
+ const sequence = new Sequence(mockAnimationGroups, { offsetEasing: customEasing });
+
+ expect(sequence.offsetEasing).toBe(customEasing);
+ });
+
+ test('should fall back to linear for unknown easing names', () => {
+ const sequence = new Sequence(mockAnimationGroups, { offsetEasing: 'unknownEasing' });
+
+ // Should fall back to linear
+ expect(sequence.offsetEasing(0.5)).toBe(0.5);
+ });
+
+ test('should calculate correct offsets on construction', () => {
+ const sequence = new Sequence(mockAnimationGroups, { offset: 100 });
+ const offsets = sequence.getOffsets();
+
+ // 3 groups, linear easing, 100ms offset
+ // last = 2
+ // linear(0/2) * 2 * 100 = 0
+ // linear(1/2) * 2 * 100 = 100
+ // linear(2/2) * 2 * 100 = 200
+ expect(offsets).toEqual([0, 100, 200]);
+ });
+
+ test('should apply delays to animation groups', () => {
+ // Creating the sequence applies delays to the animation groups
+ // 3 groups with offset 100: offsets = [0, 100, 200], totalSpan = 200
+ new Sequence(mockAnimationGroups, { delay: 50, offset: 100 });
+
+ // First group: delay = 50 + 0 = 50, existing delay = 0 → total = 50, endDelay = 200 - 0 = 200
+ expect(mockAnimations[0].effect.updateTiming).toHaveBeenCalledWith({
+ delay: 50,
+ endDelay: 200,
+ });
+
+ // Second group: delay = 50 + 100 = 150, existing delay = 100 → total = 250, endDelay = 200 - 100 = 100
+ expect(mockAnimations[1].effect.updateTiming).toHaveBeenCalledWith({
+ delay: 250,
+ endDelay: 100,
+ });
+
+ // Third group: delay = 50 + 200 = 250, existing delay = 50 → total = 300, endDelay = 200 - 200 = 0
+ expect(mockAnimations[2].effect.updateTiming).toHaveBeenCalledWith({
+ delay: 300,
+ endDelay: 0,
+ });
+ });
+
+ test('getOffsetAt should return offset for specific index', () => {
+ const sequence = new Sequence(mockAnimationGroups, { offset: 150 });
+
+ expect(sequence.getOffsetAt(0)).toBe(0);
+ expect(sequence.getOffsetAt(1)).toBe(150);
+ expect(sequence.getOffsetAt(2)).toBe(300);
+ expect(sequence.getOffsetAt(99)).toBe(0); // out of bounds
+ });
+
+ test('recalculateOffsets should update offsets', () => {
+ const sequence = new Sequence(mockAnimationGroups, { offset: 100 });
+ expect(sequence.getOffsets()).toEqual([0, 100, 200]);
+
+ // Change offset and recalculate
+ sequence.offset = 200;
+ sequence.recalculateOffsets();
+
+ expect(sequence.getOffsets()).toEqual([0, 200, 400]);
+ });
+
+ // Note: play(), pause(), reverse(), cancel(), setPlaybackRate(), onFinish(),
+ // finished, and playState are inherited from AnimationGroup.
+ // These tests verify that the underlying animations are controlled correctly.
+
+ test('play should call play on all underlying animations', async () => {
+ const sequence = new Sequence(mockAnimationGroups);
+
+ await sequence.play();
+
+ for (const animation of mockAnimations) {
+ expect(animation.play).toHaveBeenCalled();
+ }
+ });
+
+ test('play should execute callback after all animations are ready', async () => {
+ const sequence = new Sequence(mockAnimationGroups);
+ const callback = vi.fn();
+
+ await sequence.play(callback);
+
+ expect(callback).toHaveBeenCalled();
+ });
+
+ test('pause should call pause on all underlying animations', () => {
+ const sequence = new Sequence(mockAnimationGroups);
+
+ sequence.pause();
+
+ for (const animation of mockAnimations) {
+ expect(animation.pause).toHaveBeenCalled();
+ }
+ });
+
+ test('reverse should call reverse on all underlying animations', async () => {
+ const sequence = new Sequence(mockAnimationGroups);
+
+ await sequence.reverse();
+
+ for (const animation of mockAnimations) {
+ expect(animation.reverse).toHaveBeenCalled();
+ }
+ });
+
+ test('reverse should execute callback after all animations are ready', async () => {
+ const sequence = new Sequence(mockAnimationGroups);
+ const callback = vi.fn();
+
+ await sequence.reverse(callback);
+
+ expect(callback).toHaveBeenCalled();
+ });
+
+ test('cancel should call cancel on all underlying animations', () => {
+ const sequence = new Sequence(mockAnimationGroups);
+
+ sequence.cancel();
+
+ for (const animation of mockAnimations) {
+ expect(animation.cancel).toHaveBeenCalled();
+ }
+ });
+
+ test('setPlaybackRate should set rate on all underlying animations', () => {
+ const sequence = new Sequence(mockAnimationGroups);
+
+ sequence.setPlaybackRate(2);
+
+ for (const animation of mockAnimations) {
+ expect(animation.playbackRate).toBe(2);
+ }
+ });
+
+ test('onFinish should call callback when all animations finish', async () => {
+ const sequence = new Sequence(mockAnimationGroups);
+ const callback = vi.fn();
+
+ await sequence.onFinish(callback);
+
+ expect(callback).toHaveBeenCalled();
+ });
+
+ test('finished getter should return promise that resolves when all animations finish', async () => {
+ const sequence = new Sequence(mockAnimationGroups);
+
+ const result = await sequence.finished;
+
+ expect(Array.isArray(result)).toBe(true);
+ });
+
+ test('playState getter should return first animation playState', () => {
+ const sequence = new Sequence(mockAnimationGroups);
+
+ // mockAnimations[0].playState is 'idle'
+ expect(sequence.playState).toBe('idle');
+ });
+
+ test('playState should return undefined for empty sequence', () => {
+ const emptySequence = new Sequence([]);
+
+ expect(emptySequence.playState).toBeUndefined();
+ });
+ });
+
+ describe('Sequence with various easing functions', () => {
+ test('should correctly apply expoIn easing', () => {
+ const result = calculateOffsets(5, 100, expoIn);
+
+ // expoIn has very slow start, fast end
+ expect(result[0]).toBe(0);
+ expect(result[1]).toBeLessThan(10); // very slow start
+ expect(result[4]).toBe(400); // ends at 4 * 100
+ });
+
+ test('should correctly apply custom exponential easing', () => {
+ const customExpo = (t: number) => t ** 4; // quartic
+
+ const result = calculateOffsets(5, 100, customExpo);
+
+ expect(result[0]).toBe(0);
+ expect(result[1]).toBeLessThan(50); // slow start
+ expect(result[4]).toBe(400);
+ });
+ });
+
+ describe('parseCubicBezier()', () => {
+ test('should parse valid cubic-bezier string', () => {
+ const fn = parseCubicBezier('cubic-bezier(0.4, 0, 0.2, 1)');
+ expect(fn).not.toBeNull();
+ expect(typeof fn).toBe('function');
+ });
+
+ test('should return correct values for standard ease-out curve', () => {
+ // Standard Material Design ease-out: cubic-bezier(0, 0, 0.2, 1)
+ const fn = parseCubicBezier('cubic-bezier(0, 0, 0.2, 1)')!;
+
+ expect(fn(0)).toBe(0);
+ expect(fn(1)).toBe(1);
+ // This ease-out starts fast (y > x for small x values)
+ expect(fn(0.25)).toBeGreaterThan(0.25);
+ expect(fn(0.5)).toBeGreaterThan(0.5);
+ });
+
+ test('should return correct values for ease-in curve', () => {
+ // Standard ease-in: cubic-bezier(0.4, 0, 1, 1)
+ const fn = parseCubicBezier('cubic-bezier(0.4, 0, 1, 1)')!;
+
+ expect(fn(0)).toBe(0);
+ expect(fn(1)).toBe(1);
+ // ease-in starts slow
+ expect(fn(0.25)).toBeLessThan(0.25);
+ expect(fn(0.5)).toBeLessThan(0.5);
+ });
+
+ test('should handle overshoot cubic-bezier values', () => {
+ // Overshoot: cubic-bezier(0.34, 1.56, 0.64, 1)
+ const fn = parseCubicBezier('cubic-bezier(0.34, 1.56, 0.64, 1)')!;
+
+ expect(fn(0)).toBe(0);
+ expect(fn(1)).toBe(1);
+ // The curve overshoots past 1 in the middle
+ const midValues = [fn(0.3), fn(0.4), fn(0.5), fn(0.6)];
+ const hasOvershoot = midValues.some((v) => v > 1);
+ expect(hasOvershoot).toBe(true);
+ });
+
+ test('should handle whitespace variations', () => {
+ const fn1 = parseCubicBezier('cubic-bezier(0.4,0,0.2,1)');
+ const fn2 = parseCubicBezier('cubic-bezier( 0.4 , 0 , 0.2 , 1 )');
+ const fn3 = parseCubicBezier('cubic-bezier( 0.4, 0, 0.2, 1 )');
+
+ expect(fn1).not.toBeNull();
+ expect(fn2).not.toBeNull();
+ expect(fn3).not.toBeNull();
+
+ // All should produce same results
+ expect(fn1!(0.5)).toBeCloseTo(fn2!(0.5), 5);
+ expect(fn2!(0.5)).toBeCloseTo(fn3!(0.5), 5);
+ });
+
+ test('should be case insensitive', () => {
+ const fn1 = parseCubicBezier('cubic-bezier(0.4, 0, 0.2, 1)');
+ const fn2 = parseCubicBezier('CUBIC-BEZIER(0.4, 0, 0.2, 1)');
+ const fn3 = parseCubicBezier('Cubic-Bezier(0.4, 0, 0.2, 1)');
+
+ expect(fn1).not.toBeNull();
+ expect(fn2).not.toBeNull();
+ expect(fn3).not.toBeNull();
+ });
+
+ test('should return null for invalid strings', () => {
+ expect(parseCubicBezier('ease')).toBeNull();
+ expect(parseCubicBezier('linear')).toBeNull();
+ expect(parseCubicBezier('cubic-bezier()')).toBeNull();
+ expect(parseCubicBezier('cubic-bezier(0.4)')).toBeNull();
+ expect(parseCubicBezier('cubic-bezier(0.4, 0, 0.2)')).toBeNull();
+ expect(parseCubicBezier('bezier(0.4, 0, 0.2, 1)')).toBeNull();
+ expect(parseCubicBezier('cubic-bezier(a, b, c, d)')).toBeNull();
+ });
+
+ test('should handle negative values', () => {
+ // Back easing uses negative values
+ const fn = parseCubicBezier('cubic-bezier(0.6, -0.28, 0.735, 0.045)')!;
+
+ expect(fn).not.toBeNull();
+ expect(fn(0)).toBe(0);
+ expect(fn(1)).toBe(1);
+ });
+
+ test('should handle values greater than 1 for y coordinates', () => {
+ // Values > 1 for y1, y2 cause overshoot
+ const fn = parseCubicBezier('cubic-bezier(0.175, 0.885, 0.32, 1.275)')!;
+
+ expect(fn).not.toBeNull();
+ expect(fn(0)).toBe(0);
+ expect(fn(1)).toBe(1);
+ });
+ });
+
+ describe('createCubicBezier()', () => {
+ test('should create linear-like function for (0, 0, 1, 1)', () => {
+ const fn = createCubicBezier(0, 0, 1, 1);
+
+ expect(fn(0)).toBe(0);
+ expect(fn(0.5)).toBeCloseTo(0.5, 2);
+ expect(fn(1)).toBe(1);
+ });
+
+ test('should create ease-out function for (0, 0, 0.2, 1)', () => {
+ const fn = createCubicBezier(0, 0, 0.2, 1);
+
+ expect(fn(0)).toBe(0);
+ expect(fn(1)).toBe(1);
+ // ease-out: fast start, slow end
+ expect(fn(0.25)).toBeGreaterThan(0.25);
+ });
+
+ test('should create ease-in function for (0.4, 0, 1, 1)', () => {
+ const fn = createCubicBezier(0.4, 0, 1, 1);
+
+ expect(fn(0)).toBe(0);
+ expect(fn(1)).toBe(1);
+ // ease-in: slow start, fast end
+ expect(fn(0.25)).toBeLessThan(0.25);
+ });
+
+ test('should handle boundary values correctly', () => {
+ const fn = createCubicBezier(0.25, 0.1, 0.25, 1);
+
+ expect(fn(0)).toBe(0);
+ expect(fn(1)).toBe(1);
+ expect(fn(-0.5)).toBe(0); // clamp below 0
+ expect(fn(1.5)).toBe(1); // clamp above 1
+ });
+ });
+
+ describe('parseLinear()', () => {
+ test('should parse simple linear(0, 1) with auto-distributed stops', () => {
+ const fn = parseLinear('linear(0, 1)');
+ expect(fn).not.toBeNull();
+
+ expect(fn!(0)).toBe(0);
+ expect(fn!(0.5)).toBe(0.5);
+ expect(fn!(1)).toBe(1);
+ });
+
+ test('should parse linear with explicit percentage stops', () => {
+ const fn = parseLinear('linear(0, 0.75 25%, 1)');
+ expect(fn).not.toBeNull();
+
+ expect(fn!(0)).toBe(0);
+ expect(fn!(0.25)).toBe(0.75); // explicit 75% at 25% progress
+ expect(fn!(1)).toBe(1);
+ // Between 25% and 100%: interpolate from 0.75 to 1
+ expect(fn!(0.625)).toBeCloseTo(0.875, 5); // midpoint between 0.25 and 1
+ });
+
+ test('should return null for invalid input', () => {
+ expect(parseLinear('not-linear')).toBeNull();
+ expect(parseLinear('linear()')).toBeNull();
+ expect(parseLinear('linear(0)')).toBeNull(); // needs at least 2 stops
+ expect(parseLinear('cubic-bezier(0, 0, 1, 1)')).toBeNull();
+ });
+ });
+
+ describe('createLinear()', () => {
+ test('should create step-like easing with multiple stops', () => {
+ // Create a "stairs" effect: 0 until 50%, then jump to 1
+ const fn = createLinear([
+ { value: 0, position: 0 },
+ { value: 0, position: 0.5 },
+ { value: 1, position: 0.5 },
+ { value: 1, position: 1 },
+ ]);
+
+ expect(fn(0)).toBe(0);
+ expect(fn(0.25)).toBe(0);
+ expect(fn(0.5)).toBe(0); // at exact boundary, returns first matching stop
+ expect(fn(0.75)).toBe(1);
+ expect(fn(1)).toBe(1);
+ });
+
+ test('should handle boundary values correctly', () => {
+ const fn = createLinear([
+ { value: 0.2, position: 0 },
+ { value: 0.8, position: 1 },
+ ]);
+
+ expect(fn(-0.5)).toBe(0.2); // clamp below returns first stop value
+ expect(fn(1.5)).toBe(0.8); // clamp above returns last stop value
+ expect(fn(0.5)).toBeCloseTo(0.5, 5); // midpoint
+ });
+ });
+
+ describe('Sequence easing resolution', () => {
+ let mockAnimationGroups: AnimationGroup[];
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ // Create mock animations
+ const createMockAnimation = () => ({
+ play: vi.fn(),
+ pause: vi.fn(),
+ reverse: vi.fn(),
+ cancel: vi.fn(),
+ ready: Promise.resolve(),
+ finished: Promise.resolve(),
+ playState: 'idle',
+ playbackRate: 1,
+ effect: {
+ getTiming: vi.fn(() => ({ delay: 0 })),
+ updateTiming: vi.fn(),
+ getComputedTiming: vi.fn(() => ({ progress: 0 })),
+ },
+ });
+
+ // Create real AnimationGroup instances
+ mockAnimationGroups = [
+ new AnimationGroup([createMockAnimation()] as unknown as Animation[]),
+ new AnimationGroup([createMockAnimation()] as unknown as Animation[]),
+ new AnimationGroup([createMockAnimation()] as unknown as Animation[]),
+ ];
+ });
+
+ describe('Named easing strings', () => {
+ test('should resolve "linear" easing name', () => {
+ const sequence = new Sequence(mockAnimationGroups, {
+ offsetEasing: 'linear',
+ offset: 100,
+ });
+
+ expect(sequence.offsetEasing(0.5)).toBeCloseTo(linear(0.5));
+ expect(sequence.getOffsets()).toEqual([0, 100, 200]);
+ });
+
+ test('should resolve "quadIn" easing name', () => {
+ const sequence = new Sequence(mockAnimationGroups, {
+ offsetEasing: 'quadIn',
+ offset: 100,
+ });
+
+ expect(sequence.offsetEasing(0.5)).toBeCloseTo(quadIn(0.5));
+ });
+
+ test('should resolve "quadOut" easing name', () => {
+ const sequence = new Sequence(mockAnimationGroups, {
+ offsetEasing: 'quadOut',
+ offset: 100,
+ });
+
+ expect(sequence.offsetEasing(0.5)).toBeCloseTo(quadOut(0.5));
+ // quadOut is faster at start, so middle item should have higher offset
+ expect(sequence.getOffsets()[1]).toBeGreaterThan(100);
+ });
+
+ test('should resolve "cubicIn" easing name', () => {
+ const sequence = new Sequence(mockAnimationGroups, {
+ offsetEasing: 'cubicIn',
+ offset: 100,
+ });
+
+ expect(sequence.offsetEasing(0.5)).toBeCloseTo(cubicIn(0.5));
+ });
+
+ test('should resolve "cubicOut" easing name', () => {
+ const sequence = new Sequence(mockAnimationGroups, {
+ offsetEasing: 'cubicOut',
+ offset: 100,
+ });
+
+ expect(sequence.offsetEasing(0.5)).toBeCloseTo(cubicOut(0.5));
+ });
+
+ test('should resolve "expoIn" easing name', () => {
+ const sequence = new Sequence(mockAnimationGroups, {
+ offsetEasing: 'expoIn',
+ offset: 100,
+ });
+
+ expect(sequence.offsetEasing(0.5)).toBeCloseTo(expoIn(0.5));
+ });
+
+ test('should resolve "sineOut" easing name', () => {
+ const sequence = new Sequence(mockAnimationGroups, {
+ offsetEasing: 'sineOut',
+ offset: 100,
+ });
+
+ expect(sequence.offsetEasing(0.5)).toBeCloseTo(sineOut(0.5));
+ });
+
+ test('should fall back to linear for unknown easing name', () => {
+ const sequence = new Sequence(mockAnimationGroups, {
+ offsetEasing: 'unknownEasing',
+ offset: 100,
+ });
+
+ expect(sequence.offsetEasing(0.5)).toBe(0.5); // linear
+ });
+ });
+
+ describe('CSS cubic-bezier strings', () => {
+ test('should resolve cubic-bezier easing string', () => {
+ const sequence = new Sequence(mockAnimationGroups, {
+ offsetEasing: 'cubic-bezier(0.4, 0, 0.2, 1)',
+ offset: 100,
+ });
+
+ expect(sequence.offsetEasing(0)).toBe(0);
+ expect(sequence.offsetEasing(1)).toBe(1);
+ // Material ease-out: fast start
+ expect(sequence.offsetEasing(0.5)).toBeGreaterThan(0.5);
+ });
+
+ test('should resolve cubic-bezier with negative values (back easing)', () => {
+ const sequence = new Sequence(mockAnimationGroups, {
+ offsetEasing: 'cubic-bezier(0.6, -0.28, 0.735, 0.045)',
+ offset: 100,
+ });
+
+ expect(sequence.offsetEasing(0)).toBe(0);
+ expect(sequence.offsetEasing(1)).toBe(1);
+ });
+
+ test('should resolve cubic-bezier with overshoot values', () => {
+ const sequence = new Sequence(mockAnimationGroups, {
+ offsetEasing: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
+ offset: 100,
+ });
+
+ expect(sequence.offsetEasing(0)).toBe(0);
+ expect(sequence.offsetEasing(1)).toBe(1);
+ // Should overshoot in the middle
+ const midValues = [0.3, 0.4, 0.5, 0.6].map((t) => sequence.offsetEasing(t));
+ expect(midValues.some((v) => v > 1)).toBe(true);
+ });
+
+ test('should handle CSS standard ease curves', () => {
+ // CSS ease: cubic-bezier(0.25, 0.1, 0.25, 1)
+ const sequence = new Sequence(mockAnimationGroups, {
+ offsetEasing: 'cubic-bezier(0.25, 0.1, 0.25, 1)',
+ offset: 100,
+ });
+
+ expect(sequence.offsetEasing(0)).toBe(0);
+ expect(sequence.offsetEasing(1)).toBe(1);
+ });
+
+ test('should fall back to linear for malformed cubic-bezier', () => {
+ const sequence = new Sequence(mockAnimationGroups, {
+ offsetEasing: 'cubic-bezier(invalid)',
+ offset: 100,
+ });
+
+ expect(sequence.offsetEasing(0.5)).toBe(0.5); // linear fallback
+ });
+ });
+
+ describe('Custom function easings', () => {
+ test('should use provided function directly', () => {
+ const customEasing = (t: number) => t * t; // quadratic
+ const sequence = new Sequence(mockAnimationGroups, {
+ offsetEasing: customEasing,
+ offset: 100,
+ });
+
+ expect(sequence.offsetEasing).toBe(customEasing);
+ expect(sequence.offsetEasing(0.5)).toBe(0.25);
+ });
+
+ test('should handle cubic function', () => {
+ const cubicFn = (t: number) => t * t * t;
+ const sequence = new Sequence(mockAnimationGroups, {
+ offsetEasing: cubicFn,
+ offset: 100,
+ });
+
+ expect(sequence.offsetEasing(0.5)).toBe(0.125);
+ });
+
+ test('should handle step function', () => {
+ const stepFn = (t: number) => (t < 0.5 ? 0 : 1);
+ const sequence = new Sequence(mockAnimationGroups, {
+ offsetEasing: stepFn,
+ offset: 100,
+ });
+
+ expect(sequence.offsetEasing(0.25)).toBe(0);
+ expect(sequence.offsetEasing(0.75)).toBe(1);
+ });
+
+ test('should handle bounce-like function', () => {
+ // Simple bounce that overshoots
+ const bounceFn = (t: number) => {
+ if (t < 0.5) return 4 * t * t * t;
+ return 1 - Math.pow(-2 * t + 2, 3) / 2;
+ };
+ const sequence = new Sequence(mockAnimationGroups, {
+ offsetEasing: bounceFn,
+ offset: 100,
+ });
+
+ expect(sequence.offsetEasing(0)).toBe(0);
+ expect(sequence.offsetEasing(1)).toBe(1);
+ });
+
+ test('should calculate offsets correctly with custom function', () => {
+ // Square root for ease-out effect
+ const sqrtEasing = (t: number) => Math.sqrt(t);
+ const sequence = new Sequence(mockAnimationGroups, {
+ offsetEasing: sqrtEasing,
+ offset: 100,
+ });
+
+ const offsets = sequence.getOffsets();
+ expect(offsets[0]).toBe(0);
+ // sqrt(0.5) ≈ 0.707, so offset should be ~141
+ expect(offsets[1]).toBeGreaterThan(130);
+ expect(offsets[1]).toBeLessThan(150);
+ expect(offsets[2]).toBe(200);
+ });
+ });
+ });
+});