@motion.page/sdk
v1.0.2
Published
High-performance CSS animation SDK with scroll, hover, gesture, and cursor triggers
Maintainers
Readme
@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.
Table of Contents
- Installation
- Quick Start
- AI Agent Support
- Core Concept
- Implicit Values
- API Reference
- AnimationConfig
- Easing
- Types
- Browser Build
- Browser Support
- License
Installation
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 inuseEffect,onMounted, or client-side scripts.
For direct browser use without a bundler, see Browser Build.
Quick Start
Basic Animation
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
// 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
// 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
// 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
// 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:
// 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 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:
# Universal (40+ agents)
npx skills add motion-page/claude-plugin
# Claude Code
/install-plugin npm:@motion.page/claude-pluginCore 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:
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
// ❌ 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
// 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
// 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 intowhen 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:
// 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[]): TimelineTargetInput 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. |
Examples
// 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.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:
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
// 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); // 50Migration 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
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 playingState — Getter / Setter
Calling with no argument reads the value; calling with an argument sets it and returns this.
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// Read
const p = tl.progress(); // e.g. 0.5
// Write (chainable)
tl.progress(0.5).play();
tl.timeScale(2).restart(); // play at 2× speedInserting Callbacks at a Position
tl.call(
callback: (...args: unknown[]) => void,
params?: unknown[],
position?: string | number,
): thisPosition 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 |
Motion('demo', '.box', { from: { opacity: 0 }, duration: 1 })
.call(() => console.log('halfway'), [], 0.5)
.call(() => console.log('done'), [], '>');Cleanup
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.
// 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.
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
}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.
interface ClickConfig {
target?: string | Element;
each?: boolean;
secondTarget?: string | Element; // Alternative click target
toggle?: 'reverse' | 'restart' | 'play';
preventDefault?: boolean;
}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.
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:
.onScroll({ start: 'top center', end: '+=800' }) // 800px of scroll travel
.onScroll({ start: 'top top', end: '+=100vh' }) // one viewport height of scrollpin: string pins a different element (e.g. a parent wrapper) while the animated child scrolls:
// 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.
// 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:
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.25snaps 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:
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}%`,
});Motion('parallax', '.hero-bg', {
to: { y: -100 },
}).onScroll({ scrub: 1, start: 'top top', end: 'bottom top' });.onMouseMove(config?)
Drive animation progress from mouse position.
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 |
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()
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.
Motion('page-intro', [
{ target: '.logo', from: { opacity: 0 }, duration: 0.5 },
{ target: '.nav', from: { y: -20, opacity: 0 }, duration: 0.4 },
]).onPageLoad();.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.
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 pagemode: 'include'— only links matchingselectorsmode: 'exclude'— all links except those matchingselectors- Automatically skips
target="_blank"links and modifier-key clicks (Cmd/Ctrl/Shift/Alt)
// 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.
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 movementUpComplete/DownComplete/etc. — fire once on release if that direction was activePressInit— fires immediately on press, before start position is recordedPress— fires after start position is recordedHover/HoverEnd— require atargetelement (not the window)playNext/playPreviousactions — only work wheneach: trueis set
animationStep can be a single number or a per-event map:
animationStep: { Up: 0.2, Down: 0.1 } // different step size per directionMotion('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.
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:
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.
}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:
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' },
});<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:
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' },
});<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
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
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 | (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).
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
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:
// 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 }
// 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 |
// 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();path
path.target accepts a CSS selector, an Element, or raw SVG path data (a string starting with M or m):
// 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:
Motion('theme', ':root', {
to: { '--primary-hue': 240, '--accent-opacity': 0.8 },
duration: 0.5,
}).onClick({ target: '#theme-btn' });StaggerVars
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:
// 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 pauseSplitType
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) | — |
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.
Motion('reveal', 'h1', {
split: 'lines',
mask: true,
from: { y: '100%' },
stagger: 0.1,
}).onPageLoad();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.
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 usingscaleX/scaleY. Fast, GPU-accelerated, but can distort text, borders, and box-shadows.resize: true— animates actualwidth/heightproperties instead. No visual distortion, but triggers layout reflow. Mutually exclusive withscale.
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.
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.
import type {
// Core
AnimationVars,
AnimationConfig,
AnimationEntry,
TargetInput,
ObjectTarget,
AnimationTarget,
EasingFunction,
// Animation options
StaggerVars,
RepeatConfig,
SplitType,
PathConfig,
FitConfig,
// 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:
bun run packages/sdk/scripts/build-iife.tsNote: This script is available in the source repository only. It is not included in the npm package.
This creates a self-contained script that exposes window.Motion and window.MotionTimeline:
<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
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 for an enterprise license.
After 2 years from each release, the code converts to the Apache 2.0 license with no restrictions at all.
See LICENSE for the full legal text.
