# @motion.page/sdk

A high-performance animation SDK with a declarative API. Scroll-triggered animations, page transitions, custom cursors, gesture controls, text splitting, and more — zero runtime dependencies.

[![npm version](https://img.shields.io/npm/v/@motion.page/sdk)](https://www.npmjs.com/package/@motion.page/sdk)
[![Bundle Size](https://img.shields.io/bundlephobia/minzip/@motion.page/sdk)](https://bundlephobia.com/package/@motion.page/sdk)
[![License](https://img.shields.io/badge/license-FSL--1.1--Apache--2.0-blue)](#license)

---

## Table of Contents

- [Installation](#installation)
- [Quick Start](#quick-start)
- [AI Agent Support](#ai-agent-support)
- [Core Concept](#core-concept)
- [Implicit Values](#implicit-values)
- [API Reference](#api-reference)
  - [Motion()](#motion-function)
  - [Motion Static Methods](#motion-static-methods)
  - [Motion.context](#motioncontext--dynamic-content--spa)
  - [Motion.responsive](#motionresponsive--responsive-breakpoint-variants)
  - [Motion.utils](#motionutils)
  - [Timeline](#timeline)
  - [Triggers](#triggers)
  - [Lifecycle Callbacks](#lifecycle-callbacks)
- [AnimationConfig](#animationconfig)
- [Easing](#easing)
- [Types](#types)
- [Browser Build](#browser-build)
- [Browser Support](#browser-support)
- [License](#license)

---

## Installation

```bash
npm install @motion.page/sdk
# or
bun add @motion.page/sdk
# or
yarn add @motion.page/sdk
# or
pnpm add @motion.page/sdk
```

> **⚠️ Browser-only:** This SDK requires a browser environment (`document`, `window`). In SSR frameworks (Next.js, Nuxt, Astro), wrap SDK calls in `useEffect`, `onMounted`, or client-side scripts.

For direct browser use without a bundler, see [Browser Build](#browser-build).

---

## Quick Start

### Basic Animation

```ts
import { Motion } from '@motion.page/sdk';

// Fade in and slide up
Motion('hero-intro', '#hero', {
  from: { opacity: 0, y: 50 },
  duration: 0.8,
  ease: 'power2.out',
}).play();
```

### Scroll-Triggered Animation

```ts
// Scrub animation progress to scroll position
Motion('scroll-reveal', '.card', {
  from: { opacity: 0, y: 40 },
  duration: 0.6,
}).onScroll({ scrub: true, start: 'top 80%', end: 'top 30%' });
```

### Hover Effect

```ts
// Play on hover, reverse on leave
Motion('btn-hover', '.btn', {
  to: { scale: 1.05, backgroundColor: '#0099ff' },
  duration: 0.3,
  ease: 'power2.out',
}).onHover({ onLeave: 'reverse' });
```

### Multi-Step Timeline with Stagger

```ts
// Sequence multiple targets; each entry has its own target and position
Motion('intro-sequence', [
  {
    target: '.title',
    from: { opacity: 0, y: -30 },
    duration: 0.6,
  },
  {
    target: '.cards',
    from: { opacity: 0, y: 20 },
    duration: 0.5,
    stagger: { each: 0.1, from: 'start' },
    position: '+=0.1',   // starts 0.1s after the previous entry ends
  },
  {
    target: '.cta',
    from: { opacity: 0, scale: 0.9 },
    duration: 0.4,
    position: '<',        // starts at the same time as the previous entry
  },
]).onPageLoad();
```

### Replay an Existing Timeline

```ts
// Retrieve a previously created timeline by name and replay it
Motion('hero-intro').restart();
```

### Object Animation

Plain JavaScript objects can be tweened — useful for canvas, audio, WebGL, or any non-DOM state:

```ts
// Animate plain JS objects (useful for canvas, audio, WebGL)
const state = { volume: 0, brightness: 100 };
Motion('audio-fade', state, {
  to: { volume: 1, brightness: 50 },
  duration: 2,
  onUpdate: () => {
    audioNode.gain.value = state.volume;
  },
}).play();
```

---

## AI Agent Support

This package includes built-in support for AI coding assistants.

**`llms.txt` included** — An `llms.txt` file (per the [llmstxt.org](https://llmstxt.org) standard) ships with the package and is automatically discoverable by AI assistants when `@motion.page/sdk` is installed in `node_modules`. It provides a structured index of the entire SDK API so agents can answer questions and generate correct code without hallucinating APIs.

**Full skill plugin** — For comprehensive AI-assisted development — complete API reference, 50+ examples, and GSAP / Framer Motion migration guides — install the official plugin:

```bash
# Universal (40+ agents)
npx skills add motion-page/claude-plugin

# Claude Code
/install-plugin npm:@motion.page/claude-plugin
```

---

## Core Concept

Every animation in the SDK is a **named timeline**. The name is the first argument to `Motion()` and acts as a registry key — calling `Motion('same-name')` always returns the same `Timeline` instance.

Timelines are built declaratively via config objects; there are no `.to()` / `.from()` method calls. Animation state (targets, transforms, styles) is managed internally by the engine.

If `Motion('name', target, config)` is called when `'name'` already has a timeline, the new entries are **appended** to the existing timeline rather than replacing it. To rebuild from scratch, call `.kill()` first:

```ts
Motion('hero').kill();
Motion('hero', '#hero', { from: { opacity: 0 }, duration: 0.8 }).onPageLoad();
```

---

## Implicit Values

The SDK automatically resolves a missing `from` or `to` by reading the element's **current computed CSS** at build time (i.e. when `Motion()` is first called). This means you rarely need to specify both ends of an animation.

### Three cases

| Config | SDK behaviour |
|--------|--------------|
| `from` only | Reads current CSS as the `to` target. Animate **from** custom values **into** the element's natural state. |
| `to` only | Reads current CSS as the `from` starting point. Animate **from** the natural state **to** custom values. |
| Both | Both endpoints are explicit. Only needed when **neither** endpoint matches the element's natural CSS. |

### Common pattern — reveal animations only need `from`

```ts
// ❌ Redundant — opacity:1 and y:0 are the element's natural CSS defaults
Motion('reveal', '.card', {
  from: { opacity: 0, y: 40 },
  to:   { opacity: 1, y: 0 },
  duration: 0.6,
}).onScroll({ scrub: true });

// ✅ Correct — SDK reads opacity:1 and y:0 from computed CSS automatically
Motion('reveal', '.card', {
  from: { opacity: 0, y: 40 },
  duration: 0.6,
}).onScroll({ scrub: true });
```

### When `to` only is correct

```ts
// Animate FROM the element's current state TO a hover state
Motion('btn-hover', '.btn', {
  to: { scale: 1.05, backgroundColor: '#0099ff' },
  duration: 0.3,
}).onHover({ onLeave: 'reverse' });
```

### When you need both

```ts
// Neither endpoint is the element's natural state
Motion('parallax', '.layer', {
  from: { x: -20, y: -20 },
  to:   { x: 20, y: 20 },
}).onMouseMove({ type: 'axis' });

// Animating between two non-default positions
Motion('swipe', '.panel', {
  from: { x: -100 },
  to:   { x: 100 },
}).onGesture({ types: ['touch'], events: { Left: 'play', Right: 'reverse' } });
```

### Natural CSS defaults (common values the SDK resolves automatically)

| Property | Natural default |
|----------|----------------|
| `opacity` | `1` |
| `x`, `y`, `z` | `0` |
| `scale`, `scaleX`, `scaleY` | `1` |
| `rotate`, `rotateX`, `rotateY` | `0` |
| `skewX`, `skewY` | `0` |

> **Note:** `height: 'auto'` is **not** a natural default for the animation engine — it must be specified explicitly in `to` when needed (e.g. accordion reveals).

### Build-time vs. play-time

The SDK reads computed CSS **at build time** (when `Motion()` is called), not at play time. If the element's styles change after the timeline is created, call `.kill()` and rebuild the timeline.

### Edge case — transform cache

Transform properties (`x`, `y`, `scale`, `rotate`, etc.) are read from the SDK's **internal transform cache** rather than `getComputedStyle`. This ensures composited transforms remain consistent across animations. Plain CSS properties (`opacity`, `color`, `width`, etc.) are read directly from `getComputedStyle`.

---

## API Reference

### Motion Function

Three overloads:

```ts
// 1. Retrieve an existing named timeline
Motion(name: string): Timeline

// 2. Create a single-animation timeline
Motion(name: string, target: TargetInput, config: AnimationConfig): Timeline

// 3. Create a multi-step timeline from an array of entries
Motion(name: string, animations: AnimationEntry[]): Timeline
```

**`TargetInput`** accepts: CSS selector string, `string[]`, `Element`, `NodeList`, `Element[]`, or a plain object / array of plain objects (for object tweening).

Calling `Motion()` with the same name on an already-existing timeline returns it unchanged (the retrieve overload). To rebuild a timeline, call `.kill()` on the old one first.

---

### Motion Static Methods

| Method | Signature | Description |
|--------|-----------|-------------|
| `Motion.set` | `(target: TargetInput, vars: AnimationVars): void` | Immediately apply CSS / transform properties with no animation. Goes through the full animation engine pipeline (including color parsing and transform compositing) but completes in zero time, so applied values persist on the DOM. |
| `Motion.get` | `(name: string): Timeline \| undefined` | Get a timeline by name; returns `undefined` if none exists. |
| `Motion.has` | `(name: string): boolean` | Check whether a named timeline is registered. |
| `Motion.getNames` | `(): string[]` | Return the names of all registered timelines. |
| `Motion.kill` | `(name: string): void` | Kill a single timeline by name. Restores initial CSS. |
| `Motion.killAll` | `(): void` | Kill every timeline and animation managed by the engine. |
| `Motion.reset` | `(targets: TargetInput): void` | Kill animations targeting those elements, revert text splits, clear transform cache and inline animation styles. |
| `Motion.refreshScrollTriggers` | `(): void` | Recalculate all scroll trigger start/end positions (call after layout changes). |
| `Motion.cleanup` | `(): void` | Remove ScrollTrigger spacer and marker DOM nodes from the document. |
| `Motion.context` | `(fn: () => void): MotionContext` | Create a scoped context that tracks all timelines created during `fn()`. Returns a `MotionContext` with `revert()`, `refresh()`, and `add(fn)` methods. Essential for dynamic content (AJAX filters, SPA navigation, infinite scroll). |
| `Motion.responsive` | `(name, variants, breakpoints): ResponsiveManager` | Register a reactive timeline whose per-breakpoint variant is live-swapped as the viewport crosses a breakpoint — no page reload. `variants` is a sparse map of `desktop` / `laptop` / `tablet` / `phone` factories; `breakpoints` is `{ laptops, tablets, phones }` in px. Returns a manager with `getActiveTier()` and `destroy()`. |

#### Examples

```ts
// Immediately hide an element before animating it in
Motion.set('#hero', { opacity: 0, y: -20 });

// Check and conditionally replay
if (Motion.has('hero-intro')) {
  Motion('hero-intro').restart();
}

// Tear down everything (e.g., on page navigation)
Motion.killAll();
Motion.cleanup();

// Reset a specific element to its original state
Motion.reset('.animated-card');
```

#### Motion.context — Dynamic Content & SPA

Use `Motion.context()` when DOM content changes without a full page reload (AJAX filters, SPA routing, infinite scroll, pagination). It tracks all timelines created inside the callback, enabling clean teardown and re-initialization.

```ts
// Create a context — all Motion() calls inside are tracked
const ctx = Motion.context(() => {
  Motion('hero', '.title', {
    from: { opacity: 0, y: 30 },
    duration: 0.8,
  }).onPageLoad();

  Motion('cards', '.card', {
    from: { y: 100 },
    duration: 1,
  }).onScroll({ scrub: true });
});

// After AJAX content replacement:
ctx.refresh();  // kills old animations, re-runs init, selectors re-resolve

// React cleanup:
useEffect(() => {
  const ctx = Motion.context(() => { /* animations */ });
  return () => ctx.revert();  // clean teardown on unmount
}, []);

// Astro view transitions:
let ctx;
document.addEventListener('astro:page-load', () => {
  ctx?.revert();
  ctx = Motion.context(() => { /* animations */ });
});

// Add more animations to existing context (e.g., lazy-loaded content):
ctx.add(() => {
  Motion('lazy', '.lazy-card', { from: { opacity: 0 }, duration: 0.5 }).onScroll({ scrub: true });
});
```

#### Motion.responsive — Responsive Breakpoint Variants

Use `Motion.responsive()` to give one timeline a different animation per viewport tier. Instead of compiling a separate block per breakpoint, you register a **sparse map of variant factories** and the SDK live-swaps the active variant as the viewport crosses a breakpoint — it kills the previous tier's timeline and builds the new one, with **no page reload**.

```ts
Motion.responsive(
  'hero',
  {
    // Each tier is a factory that builds AND registers its timeline.
    desktop: () =>
      Motion('hero', '.title', {
        from: { opacity: 0, x: -120 },
        duration: 1,
      }).onPageLoad(),

    phone: () =>
      Motion('hero', '.title', {
        from: { opacity: 0, y: 40 },
        duration: 0.6,
      }).onPageLoad(),
  },
  { laptops: 992, tablets: 768, phones: 576 } // breakpoint boundaries in px
);
```

**Tiers & ranges.** There are four tiers — `desktop`, `laptop`, `tablet`, `phone`. The `breakpoints` object sets the boundaries, and each tier owns a width range (with the values above):

| Tier | Active range |
|------|--------------|
| `desktop` | `≥ 993px` (above `laptops`) |
| `laptop` | `769px – 992px` |
| `tablet` | `577px – 768px` |
| `phone` | `≤ 576px` (at or below `phones`) |

**Sparse maps cascade up.** You only define the tiers you care about. A tier with no variant falls back to the nearest **wider** tier that does. In the example above, `laptop` and `tablet` widths both use the `desktop` variant; only `phone` width switches to the phone variant.

**Return value — the manager.** `Motion.responsive()` returns a `ResponsiveManager`:

```ts
const hero = Motion.responsive('hero', { /* variants */ }, { laptops: 992, tablets: 768, phones: 576 });

hero.getActiveTier(); // 'desktop' | 'laptop' | 'tablet' | 'phone' | null — the tier currently driving the page
hero.destroy();       // remove the breakpoint listeners and kill the active variant
```

> In SSR or environments without `matchMedia`, the `desktop` variant is built once with no live swapping. If the responsive runtime is tree-shaken out of a custom bundle that has no responsive timelines, the call best-effort builds the `desktop` variant and returns `undefined`.

---

### Motion.utils

GSAP-compatible utility functions accessible via `Motion.utils`. These are drop-in replacements for `gsap.utils.*` helpers.

`MotionUtils` is also exported directly from the package and can be imported independently:

```ts
import { MotionUtils } from '@motion.page/sdk';
// Same object as Motion.utils
```

| Method | Signature | Description |
|--------|-----------|-------------|
| `toArray` | `(target, scope?) → Element[]` | Convert CSS selector, NodeList, HTMLCollection, or Element to a flat array. Drop-in for `gsap.utils.toArray()`. |
| `clamp` | `(min, max, value?) → number \| fn` | Clamp a value between min and max. Curried if value omitted. |
| `random` | `(min, max, snap?) → number` | Random number between min and max. Optional snap increment. |
| `snap` | `(snapTo, value?) → number \| fn` | Snap to nearest increment or array value. Curried if value omitted. |
| `interpolate` | `(start, end, progress) → number` | Linear interpolation (lerp) between two values. |
| `mapRange` | `(inMin, inMax, outMin, outMax, value?) → number \| fn` | Map a value from one range to another. Curried if value omitted. |
| `normalize` | `(min, max, value?) → number \| fn` | Normalize a value to 0–1 within a range. Curried if value omitted. |
| `wrap` | `(min, max, value?) → number \| fn` | Wrap a value within a range (modular arithmetic). Curried if value omitted. |

#### Examples

```ts
// Convert selector to array (replaces gsap.utils.toArray)
const sections = Motion.utils.toArray('.section');
console.log(sections.length); // number of matching elements

// Clamp
Motion.utils.clamp(0, 100, 150);     // 100
const clamp01 = Motion.utils.clamp(0, 1);
clamp01(1.5);                         // 1

// Snap to increment
Motion.utils.snap(5, 13);            // 15
Motion.utils.snap([0, 25, 50], 30);  // 25

// Random with snap
Motion.utils.random(0, 100, 10);     // 0, 10, 20, ..., 100

// Map between ranges
Motion.utils.mapRange(0, 100, 0, 1, 50);  // 0.5

// Normalize to 0–1
Motion.utils.normalize(0, 255, 128);  // ~0.502

// Wrap (modular)
Motion.utils.wrap(0, 360, 450);       // 90

// Interpolate (lerp)
Motion.utils.interpolate(0, 100, 0.5); // 50
```

#### Migration from GSAP

| GSAP | Motion SDK |
|------|------------|
| `gsap.utils.toArray('.el')` | `Motion.utils.toArray('.el')` |
| `gsap.utils.clamp(0, 1, v)` | `Motion.utils.clamp(0, 1, v)` |
| `gsap.utils.random(0, 100)` | `Motion.utils.random(0, 100)` |
| `gsap.utils.snap(5, v)` | `Motion.utils.snap(5, v)` |
| `gsap.utils.mapRange(0, 1, 0, 100, v)` | `Motion.utils.mapRange(0, 1, 0, 100, v)` |
| `gsap.utils.normalize(0, 100, v)` | `Motion.utils.normalize(0, 100, v)` |
| `gsap.utils.wrap(0, 360, v)` | `Motion.utils.wrap(0, 360, v)` |
| `gsap.utils.interpolate(0, 100, 0.5)` | `Motion.utils.interpolate(0, 100, 0.5)` |

---

### Timeline

`Timeline` is the object returned by every `Motion()` call. All playback and trigger methods return `this` for chaining.

#### Playback Control

```ts
tl.play(from?: number): this      // Play forward; optional start time in seconds
tl.pause(atTime?: number): this   // Pause; optional snap-to time
tl.reverse(from?: number): this   // Play backward; optional start time
tl.restart(): this                // Seek to t=0, restore initial CSS, then play forward
tl.seek(position: number): this   // Jump to a time (seconds) without playing
```

#### State — Getter / Setter

Calling with no argument reads the value; calling with an argument sets it and returns `this`.

```ts
tl.duration(): number                      // Read total duration in seconds
tl.progress(value?: number): number | this // Get or set normalized progress (0–1)
tl.time(value?: number): number | this     // Get or set current time in seconds
tl.timeScale(value?: number): number | this // Get or set playback speed multiplier
tl.isActive(): boolean                     // Is the timeline currently animating?
tl.getName(): string | undefined           // Registered name, or undefined for anonymous
```

```ts
// Read
const p = tl.progress();   // e.g. 0.5

// Write (chainable)
tl.progress(0.5).play();
tl.timeScale(2).restart();  // play at 2× speed
```

#### Inserting Callbacks at a Position

```ts
tl.call(
  callback: (...args: unknown[]) => void,
  params?: unknown[],
  position?: string | number,
): this
```

**Position syntax** (same rules apply to `AnimationEntry.position`):

| Value | Meaning |
|-------|---------|
| `0.5` | 0.5 s from start (absolute) |
| `"+=0.5"` | 0.5 s after the previous entry ends |
| `"-=0.3"` | 0.3 s before the previous entry ends |
| `"<"` | At the same start time as the previous entry |
| `">"` | Immediately after the previous entry ends |
| `"<0.2"` | 0.2 s after the start of the previous entry |
| `">-0.1"` | 0.1 s before the end of the previous entry |

```ts
Motion('demo', '.box', { from: { opacity: 0 }, duration: 1 })
  .call(() => console.log('halfway'), [], 0.5)
  .call(() => console.log('done'), [], '>');
```

#### Cleanup

```ts
tl.kill(clearProps?: boolean): void
// Destroy the timeline. clearProps=true (default) restores initial CSS on all targets.

tl.clear(): this
// Reset and rebuild the timeline without destroying it.
```

---

### Triggers

All trigger methods are chainable and attach behaviour to the timeline without requiring you to manage event listeners manually.

#### Per-Element Triggers (`each: true`)

When targeting multiple elements, `each: true` creates independent per-element timeline instances. Without it, all matched elements share one timeline and play/reverse together.

```ts
// WITHOUT each — hovering any card plays ALL cards
Motion('card-hover', '.card', { to: { y: -8 }, duration: 0.3 })
  .onHover({ onLeave: 'reverse' });

// WITH each — each card animates independently
Motion('card-hover', '.card', { to: { y: -8 }, duration: 0.3 })
  .onHover({ each: true, onLeave: 'reverse' });
```

`each` is supported by `.onHover()`, `.onClick()`, `.onScroll()`, `.onMouseMove()`, and `.onGesture()`.

#### `.onHover(config?)`

Play on `mouseenter`, react on `mouseleave`.

```ts
interface HoverConfig {
  target?: string | Element;              // Defaults to animation target(s)
  each?: boolean;                         // Apply trigger to each matched element individually
  onLeave?: 'reverse' | 'pause' | 'stop' | 'restart' | 'none';
  leaveDelay?: number;                    // Seconds to wait before triggering onLeave
}
```

```ts
Motion('card-hover', '.card', {
  to: { y: -8, boxShadow: '0 12px 24px rgba(0,0,0,0.15)' },
  duration: 0.3,
  ease: 'power2.out',
}).onHover({ each: true, onLeave: 'reverse' });
```

#### `.onClick(config?)`

Toggle animation on click.

```ts
interface ClickConfig {
  target?: string | Element;
  each?: boolean;
  secondTarget?: string | Element;        // Alternative click target
  toggle?: 'reverse' | 'restart' | 'play';
  preventDefault?: boolean;
}
```

```ts
Motion('menu-toggle', '#menu', {
  from: { height: 0, opacity: 0 },
  to:   { height: 'auto' },  // height: 'auto' must be explicit
  duration: 0.4,
  ease: 'power2.inOut',
}).onClick({ target: '#menu-btn', toggle: 'reverse' });
```

#### `.onScroll(config?)`

Scrub or snap animation to scroll position.

```ts
interface ScrollConfig {
  target?: string | Element;
  start?: string;                         // e.g. 'top 80%'
  end?: string;                           // e.g. 'bottom 20%'
  scrub?: boolean | number;               // true = instant, number = smoothing seconds
  snap?: number | number[] | ((progress: number) => number);  // Snap scroll progress
  markers?: boolean | MarkerConfig;       // Debug markers (pass object for styling)
  scroller?: string | Element;            // Custom scroll container
  pin?: boolean | string;                 // true = pin animation target; string = pin a different element
  pinSpacing?: boolean | 'margin' | 'padding';
  each?: boolean;
  toggleActions?: string;                 // Format: 'onEnter onLeave onEnterBack onLeaveBack'
}
```

**`start` / `end` defaults:**
- Without `pin`: `start: 'top bottom'`, `end: 'bottom top'`
- With `pin`: `start: 'top top'`, `end: 'bottom top'`

**Relative `end` with `+=`:** When `end` starts with `+=`, the value is a distance measured from the `start` position:

```ts
.onScroll({ start: 'top center', end: '+=800' })    // 800px of scroll travel
.onScroll({ start: 'top top', end: '+=100vh' })     // one viewport height of scroll
```

**`pin: string`** pins a different element (e.g. a parent wrapper) while the animated child scrolls:

```ts
// Pin the parent section while the child content animates
Motion('content-reveal', '.content', {
  from: { opacity: 0, y: 40 },
  duration: 1,
}).onScroll({ scrub: true, pin: '.section-wrapper', start: 'top top', end: '+=600' });
```

**`toggleActions`** controls what happens at each scroll boundary. Format: `"onEnter onLeave onEnterBack onLeaveBack"`. Default: `"play reverse play reverse"`. Valid actions: `play`, `pause`, `resume`, `reverse`, `restart`, `reset`, `complete`, `none`.

```ts
// Play once — never reverse (common for reveal animations)
.onScroll({ toggleActions: 'play none none none' });

// Re-animate every time it enters the viewport
.onScroll({ toggleActions: 'restart none none reset' });
```

**`markers`** accepts `true` for default debug markers, or a `MarkerConfig` object for custom styling:

```ts
interface MarkerConfig {
  startColor?: string;   // Default: 'green'
  endColor?: string;     // Default: 'red'
  fontSize?: string;     // e.g. '12px'
  fontWeight?: string;
  indent?: number;       // Horizontal offset in pixels
}
```

**`snap`** controls how scroll progress snaps to discrete values:

- **Number** — fractional increment to snap to. E.g. `snap: 0.25` snaps to 0, 0.25, 0.5, 0.75, 1.
- **Array** — explicit progress values to snap to. E.g. `snap: [0, 0.33, 0.66, 1]`.
- **Function** — custom snap logic. Receives raw progress (0–1), returns snapped value.

Common pattern for horizontal scroll with equal sections:

```ts
const sections = Motion.utils.toArray('.section');
Motion('h-scroll', '.panel', {
  to: { x: `-${(sections.length - 1) * 100}%` },
  duration: 1,
}).onScroll({
  scrub: true,
  snap: 1 / (sections.length - 1),
  pin: true,
  start: 'top top',
  end: `+=${sections.length * 100}%`,
});
```

```ts
Motion('parallax', '.hero-bg', {
  to: { y: -100 },
}).onScroll({ scrub: 1, start: 'top top', end: 'bottom top' });
```

#### `.onMouseMove(config?)`

Drive animation progress from mouse position.

```ts
interface MouseMoveConfig {
  type?: 'distance' | 'axis';
  target?: string | Element;              // Element whose bounds define the movement area
  each?: boolean;
  smooth?: number;                        // Smoothing factor (higher = slower follow)
  startProgress?: number;                 // Progress value at rest (0–1)
  leaveProgress?: number;                 // Progress to animate to on mouse leave
}
```

**Defaults:**

| Option | Default | Notes |
|--------|---------|-------|
| `type` | `'distance'` | |
| `startProgress` | `0.5` | Progress value when mouse is at rest |
| `leaveProgress` | `0.5` | Progress to animate to on mouse leave |
| `smooth` | `0.1` | `0` = instant tracking, `1` = maximum lag |

```ts
Motion('parallax-depth', '.layer', {
  from: { x: -20, y: -20 },
  to:   { x: 20, y: 20 },
  axis: 'x',  // bind to horizontal axis only
}).onMouseMove({ type: 'axis', smooth: 0.1 });
```

#### `.onPageLoad(config?)`

Play the animation automatically when the page finishes loading. If called after `DOMContentLoaded` has already fired (common in SPAs or scripts placed at the bottom of `<body>`), the animation plays immediately.

```ts
Motion('page-intro', [
  { target: '.logo',  from: { opacity: 0 }, duration: 0.5 },
  { target: '.nav',   from: { y: -20, opacity: 0 }, duration: 0.4 },
]).onPageLoad();
```

**`paused` option** — build the timeline (applies initial states) but don't auto-play. Start it manually with `Motion(name).play()`:

```ts
// Load timeline paused — user triggers manually
Motion('hero', '.box', { from: { opacity: 0 }, to: { opacity: 1 }, duration: 1 })
  .onPageLoad({ paused: true })

// Later, trigger manually:
Motion('hero').play()
```

Can combine with `timing`:

```ts
Motion('hero', '.box', { from: { opacity: 0 }, to: { opacity: 1 }, duration: 1 })
  .onPageLoad({ timing: 'after', paused: true })
```

#### `.onPageExit(config?)`

Intercept link clicks, play the exit animation, then navigate to the destination URL after the timeline completes. Works on any website with no server-side dependencies.

```ts
interface PageExitConfig {
  /** 'all' (default) | 'include' | 'exclude' */
  mode?: 'all' | 'include' | 'exclude';
  /** CSS selectors for links — required when mode is 'include' or 'exclude' */
  selectors?: string;
  /** Href patterns to skip automatically. Note: 'mailto' also skips tel: links. */
  skipHref?: ('anchor' | 'javascript' | 'mailto')[];
}
```

- `mode: 'all'` (default) — intercepts every `<a>` on the page
- `mode: 'include'` — only links matching `selectors`
- `mode: 'exclude'` — all links except those matching `selectors`
- Automatically skips `target="_blank"` links and modifier-key clicks (Cmd/Ctrl/Shift/Alt)

```ts
// Fade-out on page exit — all links
Motion('page-exit', 'body', {
  to: { opacity: 0 },
  duration: 0.4,
  ease: 'power2.in',
}).onPageExit();

// Only internal nav links
Motion('page-exit', 'body', {
  to: { opacity: 0 },
  duration: 0.4,
}).onPageExit({
  mode: 'include',
  selectors: 'nav a',
  skipHref: ['anchor', 'mailto'],
});
```

#### `.onGesture(config)`

Respond to pointer, touch, wheel, or scroll gestures with fine-grained event-to-action mapping.

```ts
type GestureInputType = 'pointer' | 'touch' | 'wheel' | 'scroll';

type GestureEvent =
  | 'Up' | 'Down' | 'Left' | 'Right'
  | 'UpComplete' | 'DownComplete' | 'LeftComplete' | 'RightComplete'
  | 'Change' | 'ChangeX' | 'ChangeY'
  | 'ToggleX' | 'ToggleY'
  | 'Press' | 'Release' | 'PressInit'
  | 'Drag' | 'DragEnd'
  | 'Stop' | 'Hover' | 'HoverEnd';

type GestureAction =
  | 'play' | 'pause' | 'reverse' | 'restart' | 'toggle' | 'reset' | 'complete' | 'kill'
  | 'playReverse' | 'progressUp' | 'progressDown' | 'playNext' | 'playPrevious';

interface GestureConfig {
  target?: string | Element;
  types: GestureInputType[];
  events: Partial<Record<GestureEvent, GestureAction>>;
  tolerance?: number;
  dragMinimum?: number;
  wheelSpeed?: number;
  scrollSpeed?: number;
  preventDefault?: boolean;
  lockAxis?: boolean;
  each?: boolean;
  stopDelay?: number;
  animationStep?: number | Partial<Record<GestureEvent, number>>;
  smooth?: number;
}
```

**Config defaults:**

| Option | Default | Unit | Notes |
|--------|---------|------|-------|
| `tolerance` | `1` | px | Min movement before direction events fire |
| `dragMinimum` | `10` | px | Distance before `Drag` fires |
| `wheelSpeed` | `1` | multiplier | Scales wheel delta |
| `scrollSpeed` | `1` | multiplier | Scales scroll delta |
| `stopDelay` | `150` | ms | Idle time after movement before `Stop` fires |
| `smooth` | `0` | 0–1 | Smoothness for `progressUp`/`progressDown` actions |
| `animationStep` | `0.1` | 0–1 | Progress step per event for `progressUp`/`progressDown` |

**Event distinctions:**

- `Up`/`Down`/`Left`/`Right` — fire **continuously** during movement
- `UpComplete`/`DownComplete`/etc. — fire **once on release** if that direction was active
- `PressInit` — fires immediately on press, before start position is recorded
- `Press` — fires after start position is recorded
- `Hover`/`HoverEnd` — require a `target` element (not the window)
- `playNext`/`playPrevious` actions — only work when `each: true` is set

`animationStep` can be a single number or a per-event map:

```ts
animationStep: { Up: 0.2, Down: 0.1 }  // different step size per direction
```

```ts
Motion('swipe-gallery', '.gallery', {
  to: { x: -100 },
}).onGesture({
  types: ['pointer', 'touch'],
  events: {
    Left:  'playNext',
    Right: 'playPrevious',
  },
  dragMinimum: 40,
  lockAxis: true,
});
```

#### `.onCursor(config)`

Replace the native cursor with a fully animated custom cursor.

```ts
interface CursorConfig {
  type?: 'basic' | 'text' | 'media';
  smooth?: number;
  squeeze?: boolean | { min?: number; max?: number; multiplier?: number };
  hideNative?: boolean;
  default: CursorStateVars;             // Required: default cursor appearance
  hover?: CursorStateVars;              // State when hovering interactive elements
  click?: CursorStateVars;             // State while pressing
  text?: Record<string, string | number>;
  media?: Record<string, string | number>;
}
```

**`CursorStateVars`** is the shape used for `default`, `hover`, and `click`. The `targets` field controls which elements trigger that state:

```ts
interface CursorStateVars {
  targets?: string[];    // CSS selectors that trigger this state (e.g. ['a', 'button', '.btn'])
  duration?: number;     // Transition duration in seconds (default: 0.15)
  ease?: string;         // Easing (default: 'power3.inOut')
  enabled?: boolean;     // Whether state is active (default: true)
  [key: string]: any;    // Any CSS property: width, height, backgroundColor, scale, etc.
}
```

```ts
Motion('custom-cursor', 'body', {
  to: { opacity: 1 },
  duration: 0,
}).onCursor({
  smooth: 0.08,
  hideNative: true,
  default: { width: 12, height: 12, borderRadius: '50%', backgroundColor: '#fff' },
  hover: {
    targets: ['a', 'button', '[data-cursor-hover]'],
    width: 40, height: 40,
    backgroundColor: 'transparent',
    borderColor: '#fff',
  },
  click: { scale: 0.8 },
});
```

**`type: 'text'`** — reads text from `mp-cursor-text` or `mp-cursor-tooltip` HTML attributes and displays it inside the cursor element. The `text` config object sets CSS properties on the text node:

```ts
Motion('cursor', 'body', { to: { opacity: 1 }, duration: 0 }).onCursor({
  type: 'text',
  hideNative: true,
  default: { width: 12, height: 12, borderRadius: '50%', backgroundColor: '#fff' },
  hover:   { width: 64, height: 64 },
  text: { fontSize: '12px', color: '#000', fontWeight: 'bold' },
});
```

```html
<a href="/about" mp-cursor-text="About Us">About</a>
<button mp-cursor-tooltip="Click me">Button</button>
```

**`type: 'media'`** — reads an image or video URL from the `mp-cursor-media` attribute and renders it inside the cursor. Supports http/https and relative URLs. The `media` config object sets CSS on the media element:

```ts
Motion('cursor', 'body', { to: { opacity: 1 }, duration: 0 }).onCursor({
  type: 'media',
  hideNative: true,
  default: { width: 48, height: 48, borderRadius: '50%' },
  hover:   { width: 120, height: 80 },
  media: { borderRadius: '8px', objectFit: 'cover' },
});
```

```html
<div class="project-card" mp-cursor-media="/images/preview.jpg">…</div>
<div class="video-card" mp-cursor-media="https://cdn.example.com/preview.mp4">…</div>
```

---

### Lifecycle Callbacks

Attach callbacks via the `AnimationConfig` object or directly on the `Timeline` instance. Both approaches are chainable.

#### Via `AnimationConfig`

```ts
Motion('slide-in', '.card', {
  from: { opacity: 0, x: -40 },
  duration: 0.6,
  onStart:           () => console.log('started'),
  onUpdate:          (progress) => console.log('progress:', progress),
  onComplete:        () => console.log('done'),
  onRepeat:          (count) => console.log('repeat #', count),
  onReverseComplete: () => console.log('reversed'),
  repeat: { times: 2, yoyo: true, delay: 0.5 },
});
```

#### Via `Timeline` Methods

```ts
Motion('slide-in', '.card', { from: { opacity: 0 }, duration: 0.6 })
  .onStart(() => console.log('started'))
  .onUpdate((progress, time) => console.log(progress, time))
  .onComplete(() => console.log('done'));
```

| Callback | Timeline method | AnimationConfig field | Arguments |
|----------|-----------------|-----------------------|-----------|
| Start | `.onStart(cb)` | `onStart` | none |
| Update | `.onUpdate(cb)` | `onUpdate` | `(progress: number, time: number)` via method; `(progress: number)` via config |
| Complete | `.onComplete(cb)` | `onComplete` | none |
| Repeat | `.onRepeat(cb)` | `onRepeat` | `(repeatCount: number)` |
| Reverse complete | — | `onReverseComplete` | none |

---

## AnimationConfig

Full shape of the config object used for both single-animation (`Motion(name, target, config)`) and each entry in a multi-step timeline (`AnimationEntry`).

```ts
interface AnimationConfig {
  from?:     AnimationVars;          // Initial values (animated FROM these)
  to?:       AnimationVars;          // Target values (animated TO these)
  duration?: number;                 // Seconds (default: engine default)
  delay?:    number;                 // Seconds before animation begins
  ease?:     string;                 // Easing name string, see Easing section
  stagger?:  number | StaggerVars;  // Per-element stagger delay
  repeat?:   number | RepeatConfig; // Repeat count shorthand or full config (see below)
  split?:    SplitType;             // Text splitting for per-char/word/line animation
  mask?:     boolean;               // Wrap split elements in overflow:hidden for reveal effects
  maskPadding?: MaskPadding;        // Extra split mask bleed; numbers are em values
  flap?:     FlapConfig;            // Split-flap / character scramble animation
  fit?:      FitConfig;             // FLIP-style morph toward another element
  axis?:     'x' | 'y';            // Axis binding for onMouseMove animations

  // Lifecycle
  onStart?:           () => void;
  onUpdate?:          (progress: number) => void;
  onComplete?:        () => void;
  onRepeat?:          (repeatCount: number) => void;
  onReverseComplete?: () => void;
}
```

### AnimationVars

All animatable properties:

```ts
// Transforms
x, y, z                                    // number | string (px default)
rotate, rotateX, rotateY, rotateZ          // number | string (deg default)
scale, scaleX, scaleY, scaleZ              // number
skewX, skewY                               // number | string
perspective                                // number | string

// Visual
opacity                                    // number (0–1)
backgroundColor, color                     // string (CSS color)
filter                                     // string (CSS filter)
transformOrigin                            // string

// Layout
width, height                              // number | string
top, left, right, bottom                   // number | string
margin, marginTop, marginRight, marginBottom, marginLeft
padding, paddingTop, paddingRight, paddingBottom, paddingLeft

// Typography
fontSize, lineHeight, letterSpacing        // number | string
borderRadius                               // number | string
zIndex                                     // number
backgroundPosition                         // string

// Border / outline colors
borderColor, borderTopColor, borderRightColor, borderBottomColor, borderLeftColor
outlineColor, textDecorationColor, caretColor

// SVG
fill, stroke                               // string (CSS color)
drawSVG                                    // string | { start?: number; end?: number }

// Clipping
'clip-path' / clipPath                     // string (CSS clip-path shape function)

// Motion path
path: {
  target:   string | Element;             // SVG <path> selector, element, or raw path data (starts with M/m)
  align?:   string | Element;             // Align bounding box to this element
  alignAt?: [number, number];             // Origin point [x%, y%], default [50, 50]
  start?:   number;                       // Path start (0–1), default 0
  end?:     number;                       // Path end (0–1), default 1
  rotate?:  boolean;                      // Auto-rotate along tangent
}

// CSS custom properties
'--my-var'                                 // any CSS custom property name (string)
```

### drawSVG

Animate the visible portion of an SVG stroke. The element must have a `stroke` and a set `stroke-dasharray` (or the SDK will compute it automatically).

| Format | Meaning |
|--------|---------|
| `"0% 100%"` | Full stroke visible |
| `"0% 0%"` | Stroke fully hidden (start position for a draw-in) |
| `"20% 80%"` | Middle portion only |
| `"50%"` | Shorthand for `"0% 50%"` |
| `"100px 500px"` | Pixel range along the stroke |
| `{ start: 20, end: 80 }` | Object form — values are **percentages 0–100**, not 0–1 |

```ts
// Animate stroke from hidden to fully drawn
Motion('draw-path', 'path#line', {
  from: { drawSVG: '0% 0%' },
  to:   { drawSVG: '0% 100%' },
  duration: 1.2,
  ease: 'power2.inOut',
}).onPageLoad();

// Object format — percentages, not 0–1
Motion('draw-partial', '#circle', {
  to: { drawSVG: { start: 20, end: 80 } },
  duration: 0.8,
}).play();
```

### clip-path

Animate the CSS `clip-path` property by interpolating between two shape functions of the **same type**. Both `clip-path` (kebab) and `clipPath` (camel) are accepted as the property name.

| Shape | Format |
|-------|--------|
| `circle()` | `circle(<radius> at <x> <y>)` |
| `ellipse()` | `ellipse(<rx> <ry> at <x> <y>)` |
| `inset()` | `inset(<top> <right>? <bottom>? <left>? round <radius>?)` |
| `polygon()` | `polygon(<x1> <y1>, <x2> <y2>, …)` |
| `rect()` | `rect(<top> <right> <bottom> <left> round <radius>?)` |
| `xywh()` | `xywh(<x> <y> <w> <h> round <radius>?)` |

**Interpolation rules:**
- Start and end must use the **same shape function** (e.g. `circle → circle`). Cross-shape animations snap to the end value at progress ≥ 0.5 (no smooth morphing across types).
- Polygon animations require **the same vertex count** on both ends — otherwise the animation hard-swaps at the midpoint.
- Each numeric component is linearly interpolated; CSS units (`%`, `px`, `em`, etc.) are preserved.
- The renderer writes both `clip-path` and `-webkit-clip-path` so older Safari renders correctly.

```ts
// Reveal an image by expanding a circular mask
Motion('reveal', '.cover', {
  from: { 'clip-path': 'circle(0% at 50% 50%)' },
  to:   { 'clip-path': 'circle(75% at 50% 50%)' },
  duration: 0.8,
  ease: 'power2.out',
}).onScroll({ scrub: true });

// Inset crop animation (camelCase form also works)
Motion('crop', '.image', {
  from: { clipPath: 'inset(0px round 0px)' },
  to:   { clipPath: 'inset(20px round 16px)' },
  duration: 0.6,
}).onHover({ onLeave: 'reverse' });

// Polygon slide-in (vertex counts must match)
Motion('slide', '.panel', {
  from: { 'clip-path': 'polygon(0% 0%, 0% 0%, 0% 100%, 0% 100%)' },
  to:   { 'clip-path': 'polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)' },
  duration: 0.5,
}).onPageLoad();
```

### path

`path.target` accepts a CSS selector, an `Element`, or raw SVG path data (a string starting with `M` or `m`):

```ts
// Inline path data — no DOM element required
Motion('fly', '.icon', {
  to: { path: { target: 'M 0 100 C 50 0 150 200 200 100', rotate: true } },
  duration: 2,
}).play();
```

### CSS Custom Properties

CSS custom properties can be animated by passing the property name as a string key:

```ts
Motion('theme', ':root', {
  to: { '--primary-hue': 240, '--accent-opacity': 0.8 },
  duration: 0.5,
}).onClick({ target: '#theme-btn' });
```

### StaggerVars

```ts
interface StaggerVars {
  each?:   number;                         // Seconds between each element
  amount?: number;                         // Total stagger spread (alternative to each)
  from?:   'start' | 'center' | 'edges' | 'random' | 'end' | number;
  grid?:   'auto' | [number, number];     // 2D grid stagger
  axis?:   'x' | 'y';                    // Grid stagger axis
  ease?:   string;                         // Easing applied to the stagger distribution
}
```

### RepeatConfig

The `repeat` field accepts either a plain number or a `RepeatConfig` object:

```ts
// Shorthand — number of additional repetitions
repeat: 3    // repeat 3 more times after the first play
repeat: -1   // repeat infinitely

// Full config
interface RepeatConfig {
  times: number;      // Number of additional repetitions (-1 = infinite)
  delay?: number;     // Seconds between repetitions
  yoyo?: boolean;     // Alternate direction each cycle
}

// Examples
repeat: { times: -1, yoyo: true, delay: 0.2 }  // infinite yoyo with pause between cycles
repeat: { times: 2, yoyo: true, delay: 0.5 }   // 2 extra cycles, yoyo, 0.5s pause
```

### Timeline Repeat

`.withRepeat()` loops the **entire timeline** after all its animations complete — distinct from per-animation `repeat` in `AnimationConfig`, which only loops an individual animation within the timeline.

```ts
// Shorthand — loop count (-1 = infinite, 0 = no extra loops)
tl.withRepeat(-1): this

// Full config
tl.withRepeat(config: TimelineRepeatConfig): this

interface TimelineRepeatConfig {
  times:  number;   // Number of additional repetitions (-1 = infinite)
  yoyo?:  boolean;  // Alternate direction each cycle
  delay?: number;   // Seconds between timeline repetitions
}
```

```ts
// Loop the entire timeline infinitely
Motion('hero', '.box', { from: { opacity: 0 }, to: { opacity: 1 }, duration: 1 })
  .withRepeat(-1)
  .onPageLoad()

// Loop 3 times with yoyo and delay
Motion('bounce', [
  { target: '.a', from: { y: '100%' }, to: { y: '0%' }, duration: 0.5 },
  { target: '.b', from: { opacity: 0 }, to: { opacity: 1 }, duration: 0.3, position: 0.2 }
])
  .withRepeat({ times: 3, yoyo: true, delay: 0.5 })
  .onPageLoad()
```

Use `.onRepeat()` on the `Timeline` instance to react to each completed cycle:

```ts
Motion('loop', '.el', { from: { opacity: 0 }, to: { opacity: 1 }, duration: 0.8 })
  .withRepeat(-1)
  .onRepeat((count) => console.log(`Cycle ${count} complete`))
  .onPageLoad()
```

**Per-animation vs. timeline repeat:**

| Scope | API | Effect |
|-------|-----|--------|
| Individual animation | `repeat` in `AnimationConfig` | Loops just that one animation within the timeline |
| Entire timeline | `.withRepeat()` on `Timeline` | Loops all animations in the timeline together |

### SplitType

```ts
type SplitType =
  | 'chars'
  | 'words'
  | 'lines'
  | 'chars,words'
  | 'words,lines'
  | 'chars,words,lines';
```

Text is split into wrapper `<span>` elements before animating. `Motion.reset()` reverts the DOM. Inline elements (like `<span class="accent">`) are preserved during splitting.

Split elements receive data attributes for CSS targeting:

| Attribute | Set on | Index attribute |
|-----------|--------|-----------------|
| `[data-split-char]` | each character span | `data-char-index` |
| `[data-split-word]` | each word span | `data-word-index` |
| `[data-split-line]` | each line span | `data-line-index` |
| `[data-split-mask]` | overflow wrapper (when `mask: true`) | — |

```ts
Motion('text-reveal', '.headline', {
  from:     { opacity: 0, y: 20 },
  duration: 0.5,
  split:    'chars',
  stagger:  { each: 0.03, from: 'start' },
}).onPageLoad();
```

### mask

When `mask: true` is used together with `split`, each split element (char, word, or line) is wrapped in a parent with `overflow: hidden`. This clips animated content to its natural bounds, creating a 'reveal' effect — for example, animating `y: '100%'` makes text slide up from behind an invisible edge.

```ts
Motion('reveal', 'h1', {
  split: 'lines',
  mask: true,
  from: { y: '100%' },
  stagger: 0.1,
}).onPageLoad();
```

### maskPadding

Use `maskPadding` when cursive, italic, or display fonts draw outside the glyph box and get clipped by the split mask. The SDK expands the mask clip area and applies matching negative margins, so text layout does not shift.

Numbers are interpreted as `em`; strings are used as CSS lengths. Pass a single value for both axes or an object for separate inline/block bleed.

```ts
type MaskPadding =
  | number
  | string
  | {
      inline?: number | string;
      block?: number | string;
    };
```

```ts
Motion('script-reveal', 'h1', {
  split: 'words',
  mask: true,
  maskPadding: { inline: 0.25, block: '6px' },
  from: { y: '110%' },
  stagger: 0.08,
}).onPageLoad();
```

Defaults are `0.15em` inline and `0.04em` block. They can be changed globally with CSS variables:

```css
:root {
  --motion-split-mask-padding-inline: 0.22em;
  --motion-split-mask-padding-block: 0.08em;
}
```

### flap

When `flap` is set (together with `split: 'chars'`), each character cycles through random intermediate characters before landing on its final value — creating split-flap display, scramble, or decode effects.

```ts
interface FlapConfig {
  /** Visual transition style. Default: 'flip' */
  type?: 'flip' | 'fade' | 'slide' | 'blur' | 'scale' | 'board' | 'none';
  /** Character set to cycle through. Default: 'alphanumeric' */
  charset?: CharsetPreset | string;
  /** Number of random cycles before resolving. Range tuple [min, max] picks randomly per character. Default: [2, 5] */
  cycles?: number | [number, number];
  /** Perspective depth (px) for 3D flip/board transitions. Default: 400 */
  perspective?: number;
  /** Realistic departure-board styling for 'board' type (dark gradient, split line). Default: true */
  styledBoard?: boolean;
  /**
   * Strategy for preventing layout shift during cycling:
   *   - false (default)   → no pinning; text width flexes naturally
   *   - true | 'cells'    → pin each cell to the widest glyph (classic split-flap look)
   *   - 'container'       → pin the element's outer width only, letters flow naturally
   *                         (best for inline hover flappers on nav links)
   */
  stableWidth?: boolean | 'cells' | 'container';
  /** Board type: render blank cells for space characters instead of skipping them. Default: false */
  preserveWhitespaceCells?: boolean;
}
```

**Type styles:**
- `'flip'` — 3D CSS rotateX card flip
- `'fade'` — CSS opacity fade out/in
- `'slide'` — CSS translateY slide out/in
- `'blur'` — CSS filter blur out/in
- `'scale'` — CSS scale pop/shrink
- `'board'` — mechanical split-flap departure board with two-sided flap, shadow, and split line
- `'none'` — No built-in visual effect. Use with `from`/`to` to create fully custom flap animations

#### Charset presets

| Preset | Characters |
|--------|-----------|
| `'alphanumeric'` | A–Z 0–9 |
| `'alpha'` | A–Z |
| `'numeric'` | 0–9 |
| `'hex'` | 0–9 A–F |
| `'binary'` | 0 1 |
| `'katakana'` | ァ–ン (Japanese katakana) |
| `'symbols'` | `!@#$%^&*` etc. |
| `'blocks'` | ░ ▒ ▓ █ |

Pass any custom string to cycle through your own characters: `charset: 'ABCXYZ'`.

```ts
// Styled departure board with split-flap effect
Motion('board', '.departure-text', {
  split: 'chars',
  flap: { type: 'board', styledBoard: true },
  stagger: 0.05,
}).onPageLoad();

// Classic flip animation
Motion('flip', '.title', {
  split: 'chars',
  flap: { type: 'flip', cycles: 6 },
  stagger: 0.05,
}).onPageLoad();

// Matrix-style katakana decode with random resolve order
Motion('matrix', '.terminal', {
  split: 'chars',
  flap: { type: 'fade', charset: 'katakana', cycles: [4, 8] },
  stagger: { each: 0.03, from: 'random' },
}).onPageLoad();

// Combine flap with CSS opacity + translate
Motion('combo', 'h1', {
  split: 'chars',
  flap: { type: 'flip', cycles: 4 },
  from: { opacity: 0, y: 20 },
  to: { opacity: 1, y: 0 },
  stagger: 0.04,
  duration: 0.8,
}).onPageLoad();
```

#### Per-cycle composition

When `flap` is present, all `from`/`to` properties animate **per character-swap cycle** instead of once over the full animation duration. Each cycle follows a V-curve: starts at `to`, dips to `from` at the midpoint (when the character swaps), then returns to `to`.

This lets you layer any CSS property on top of any flap type:

```ts
// Flip + fade + slide on every cycle
Motion('heading', '.title', {
  split: 'chars',
  flap: { type: 'flip' },
  from: { opacity: 0, y: 20 },
  to: { opacity: 1, y: 0 },
  duration: 2,
  stagger: 0.05,
}).play();
```

Use `type: 'none'` to build fully custom flap effects — no built-in visual, just character cycling driven entirely by your `from`/`to` properties:

```ts
// Custom scale-bounce flap with no built-in effect
Motion('custom-flap', '.label', {
  split: 'chars',
  flap: { type: 'none', cycles: 3 },
  from: { scale: 0, opacity: 0 },
  to: { scale: 1, opacity: 1 },
  duration: 1.5,
  stagger: 0.05,
}).play();
```

> **Note:** `fade` owns `opacity` and `blur` owns `filter` — these types strip conflicting user properties. Use `flip`, `scale`, `slide`, `board`, or `none` if you need full `from`/`to` control over those properties.

### FitConfig

FLIP-style morph animation. When `fit` is set, `from` and `to` are ignored — the SDK measures both elements' bounding rects at play time and animates the visual delta between them.

```ts
interface FitConfig {
  /** CSS selector for the target element to morph toward */
  target: string;
  /** Convert to absolute positioning during animation. Default: false */
  absolute?: boolean;
  /** Include scale changes. Default: true */
  scale?: boolean;
  /** Animate actual width/height instead of scaleX/scaleY. Default: false */
  resize?: boolean;
}
```

- **`scale: true`** (default) — animates using `scaleX`/`scaleY`. Fast, GPU-accelerated, but can distort text, borders, and box-shadows.
- **`resize: true`** — animates actual `width`/`height` properties instead. No visual distortion, but triggers layout reflow. Mutually exclusive with `scale`.

```ts
Motion('reorder', '.container', {
  fit: { target: '.item', resize: true },
  duration: 0.5,
  ease: 'power2.inOut',
}).play();
```

---

## Easing

Easing names are **case-insensitive** strings. Pass them to `AnimationConfig.ease` or `StaggerVars.ease`.

| Family | Variants |
|--------|----------|
| `linear`, `none` | — |
| `power1` | `power1.in` · `power1.out` · `power1.inOut` |
| `power2` | `power2.in` · `power2.out` · `power2.inOut` |
| `power3` | `power3.in` · `power3.out` · `power3.inOut` |
| `power4` | `power4.in` · `power4.out` · `power4.inOut` |
| `sine` | `sine.in` · `sine.out` · `sine.inOut` |
| `expo` | `expo.in` · `expo.out` · `expo.inOut` |
| `circ` | `circ.in` · `circ.out` · `circ.inOut` |
| `back` | `back.in` · `back.out` · `back.inOut` |
| `elastic` | `elastic.in` · `elastic.out` · `elastic.inOut` |
| `bounce` | `bounce.in` · `bounce.out` · `bounce.inOut` |

Unknown strings fall back to `power1.out`.

```ts
Motion('spring-in', '.box', {
  from: { scale: 0 },
  duration: 0.8,
  ease: 'elastic.out',
}).play();
```

---

## Types

All types are re-exported from the package entry point.

Most users won't need to import types directly — TypeScript infers everything from the `Motion()` function signature, so you get full autocomplete and type checking out of the box. These exports are provided for advanced use cases like building wrapper libraries or typing standalone config objects.

```ts
import type {
  // Core
  AnimationVars,
  AnimationConfig,
  AnimationEntry,
  TargetInput,
  ObjectTarget,
  AnimationTarget,
  EasingFunction,

  // Animation options
  StaggerVars,
  RepeatConfig,
  SplitType,
  MaskPadding,
  PathConfig,
  FitConfig,
  FlapConfig,
  CharsetPreset,

  // Triggers
  HoverConfig,
  ClickConfig,
  ScrollConfig,
  MouseMoveConfig,
  MarkerConfig,
  PageExitConfig,

  // Gesture
  GestureConfig,
  GestureEvent,
  GestureAction,
  GestureInputType,

  // Cursor
  CursorConfig,
  CursorStateVars,
  CursorSqueezeConfig,
} from '@motion.page/sdk';

// Namespace import
import { Types } from '@motion.page/sdk';
```

---

## Browser Build

An IIFE build script is included but not part of the default build output. To generate a browser bundle:

```bash
bun run packages/sdk/scripts/build-iife.ts
```

> **Note:** This script is available in the [source repository](https://motion.page) only. It is not included in the npm package.

This creates a self-contained script that exposes `window.Motion` and `window.MotionTimeline`:

```html
<script src="motion-sdk.browser.js"></script>
<script>
  const { Motion, MotionTimeline } = window;

  Motion('fade', '.hero', {
    from: { opacity: 0, y: 30 },
    duration: 0.8,
  }).onPageLoad();
</script>
```

---

## Browser Support

Modern evergreen browsers:

| Browser | Minimum |
|---------|---------|
| Chrome | 90+ |
| Firefox | 90+ |
| Safari | 15+ |
| Edge | 90+ |

---

## License

**FSL-1.1-Apache-2.0** — [Functional Source License, Version 1.1, Apache 2.0 Future License](#license)

**TL;DR:** Free for everyone. Use it in your websites, apps, SaaS products, client projects — commercial or not. One restriction: you can't use it to build a competing animation builder tool.

**The one exception:** You cannot use this SDK to build a product that competes with Motion.page (e.g., a no-code animation builder, visual animation editor, or similar tool). If you're building something like that, [contact us](mailto:hello@motion.page) for an enterprise license.

After 2 years from each release, the code converts to the [Apache 2.0](https://www.apache.org/licenses/LICENSE-2.0) license with no restrictions at all.

See [LICENSE](#license) for the full legal text.
