# Motion.page Documentation — Full Content ## Animation Presets > Full catalog of built-in presets — Entrances, Loops, Text Reveals, Smooth Transitions, Creative Effects. URL: https://motion.page/docs/sdk/animation-presets import LivePreview from "../../components/docs/blocks/LivePreview"; Presets are ready-made animation configurations built into the Motion.page Builder. Each preset pre-fills `from`, `to`, stagger, split, and repeat settings — a starting point you can customize immediately. In the **Builder**, open the Presets panel, pick a category, and click any preset to apply it to the selected element. In the **SDK**, copy the `from`/`to` values from the tables below directly into your `Motion()` call. ## SDK Usage Presets don't have a dedicated API option — use the `from`/`to` values from each preset's row directly in `AnimationConfig`: ```typescript import { Motion } from "@motion.page/sdk"; // Fade Up preset Motion("hero", "#hero", { from: { opacity: 0, y: 100 }, duration: 0.8, ease: "power2.out", }).onPageLoad(); // Pulse loop preset Motion("badge", ".badge", { to: { scale: 1.1 }, duration: 0.8, repeat: { times: -1, yoyo: true }, ease: "power1.inOut", }).onPageLoad(); // Text reveal preset (Words Slide Up) Motion("headline", "h1", { split: "words", from: { opacity: 0, y: 20 }, stagger: { each: 0.1, from: "start" }, duration: 0.7, ease: "power2.out", }).onPageLoad(); ```
Entrance
Fade Up
Loop
Pulse
Text Reveal

Words slide up

`} css={`.preset-grid { width: min(720px, 100%); display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; } .preset-card { min-height: 190px; padding: 18px; border: 1px solid rgba(153, 168, 255, 0.22); border-radius: 18px; background: linear-gradient(145deg, rgba(25, 30, 61, 0.92), rgba(8, 11, 31, 0.96)); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 18px; overflow: hidden; } .preset-label { align-self: flex-start; color: #9ea8c9; font-size: 11px; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; } .preset-shape { width: 112px; height: 76px; border-radius: 14px; background: linear-gradient(135deg, #7047eb, #a97cff); display: grid; place-items: center; color: white; font-size: 13px; font-weight: 700; box-shadow: 0 18px 50px rgba(112, 71, 235, 0.34); } .preset-dot { width: 66px; height: 66px; border-radius: 50%; background: radial-gradient(circle at 30% 25%, #fff, #6ee7ff 22%, #356bff 70%); box-shadow: 0 0 38px rgba(70, 126, 255, 0.5); } .loop-card strong { font-size: 14px; } .text-card h3 { max-width: 150px; color: #f4f5ff; font-size: clamp(20px, 3.2vw, 29px); line-height: 1.04; text-align: center; } @media (max-width: 560px) { .preset-grid { grid-template-columns: 1fr; } .preset-card { min-height: 88px; padding: 12px 14px; flex-direction: row; justify-content: space-between; } .preset-label { align-self: auto; } .preset-shape { width: 88px; height: 54px; } .preset-dot { width: 48px; height: 48px; } .text-card h3 { max-width: 125px; font-size: 18px; text-align: right; } }`} js={`import { Motion } from "https://cloud.motion.page/sdk/latest.js"; Motion("preset-fade-up", ".preset-shape", { from: { opacity: 0, y: 100 }, duration: 0.8, ease: "power2.out", repeat: { times: -1, yoyo: true, delay: 0.6 }, }).play(); Motion("preset-pulse", ".preset-dot", { to: { scale: 1.1 }, duration: 0.8, ease: "power1.inOut", repeat: { times: -1, yoyo: true, delay: 0.6 }, }).play(); Motion("preset-words", ".text-card h3", { split: "words", from: { opacity: 0, y: 20 }, stagger: { each: 0.1, from: "start" }, duration: 0.7, ease: "power2.out", repeat: { times: -1, yoyo: true, delay: 0.6 }, }).play();`} /> ## Overriding Preset Values Apply a preset in the Builder, then adjust any value — duration, ease, individual `from`/`to` properties. In the SDK, the preset values are just defaults: change whatever you need. ```typescript // Fade Up with a slower duration and spring ease Motion("section", ".section", { from: { opacity: 0, y: 60 }, // reduced offset (default: 100) duration: 1.2, // slower (common default: 0.8) ease: "elastic.out(1, 0.75)", // spring instead of power ease }).onPageLoad(); ``` --- ## Entrances **Entrance** presets animate elements _into_ their natural state — all use `from` values only. The SDK resolves the missing `to` endpoint from the element's current computed CSS. | Preset | `from` values | |--------|--------------| | **Fade In** | `opacity: 0` | | **Blur In** | `opacity: 0, filter: "blur(10px)"` | | **Fade Up** | `opacity: 0, y: 100` | | **Fade Down** | `opacity: 0, y: -100` | | **Fade Left** | `opacity: 0, x: 100` | | **Fade Right** | `opacity: 0, x: -100` | | **Scale In** | `scale: 0` | | **Scale In Soft** | `scale: 0.5, opacity: 0` | | **Zoom In** | `scale: 0.3, opacity: 0` | | **Zoom Out** | `scale: 1.3, opacity: 0` | | **Flip In X** | `rotateX: 90, opacity: 0` | | **Flip In Y** | `rotateY: 90, opacity: 0` | | **Rise & Fade** | `opacity: 0, y: 80, scale: 0.95` | | **Skew Slide** | `opacity: 0, x: -100, skewX: 15` | | **Spiral In** | `scale: 0, rotate: 180, opacity: 0` | | **Corner Pop** | `scale: 0, rotate: -90, transformOrigin: "0% 0%"` | All `x`/`y` values are in `px`. All `rotate`/`rotateX`/`rotateY` values are in degrees. ```typescript // Flip In X Motion("card", ".card", { from: { rotateX: 90, opacity: 0 }, duration: 0.7, ease: "power3.out", }).onScroll({ scrub: false, toggleActions: "play none none none" }); // Corner Pop — note the custom transform origin Motion("pop", ".badge", { from: { scale: 0, rotate: -90 }, duration: 0.5, ease: "back.out(1.7)", }).onPageLoad(); // Set transform origin separately: // Motion.set(".badge", { transformOrigin: "0% 0%" }); ``` --- ## Loops **Loop** presets animate _to_ a custom state with infinite repeat. All use `to` values only — the SDK reads the element's current CSS as the start point. | Preset | `to` values | Repeat | |--------|------------|--------| | **Pulse** | `scale: 1.1` | infinite yoyo | | **Shake X** | `x: 10` | 3× yoyo then stops | | **Shake Y** | `y: 10` | infinite yoyo | | **Wiggle** | `rotate: 5` | infinite yoyo | | **Bounce** | `y: -20` | infinite yoyo | | **Rubber Band** | `scaleX: 1.3, scaleY: 0.8` | infinite yoyo | | **Swing** | `rotate: 15, transformOrigin: "50% 0%"` | infinite yoyo | | **Pendulum** | `rotate: 20, transformOrigin: "50% 0%"` | infinite yoyo | | **Flash** | `opacity: 0.3` | infinite yoyo | | **Strobe** | `opacity: 0` | infinite yoyo | | **Flip Back** | `rotateY: 360` | infinite (no yoyo) | `x`/`y` values are in `px`. `rotate`/`rotateY` in degrees. **Shake X** repeats 3 times (plays 4 total) then stops; all others loop infinitely. ```typescript // Pulse Motion("pulse", ".cta-button", { to: { scale: 1.1 }, duration: 0.8, ease: "power1.inOut", repeat: { times: -1, yoyo: true }, }).onPageLoad(); // Swing — set the pivot point before animating Motion.set(".pendulum", { transformOrigin: "50% 0%" }); Motion("swing", ".pendulum", { to: { rotate: 15 }, duration: 1, ease: "power1.inOut", repeat: { times: -1, yoyo: true }, }).onPageLoad(); // Flip Back — continuous spin (no yoyo) Motion("spin", ".icon", { to: { rotateY: 360 }, duration: 2, ease: "none", repeat: { times: -1, yoyo: false }, }).onPageLoad(); ``` --- ## Text Reveals **Text Reveal** presets split text into characters, words, or lines and stagger them in. All use `from` values with a `split` type and `stagger` configuration. ### Words | Preset | `from` values | Stagger `each` | Stagger `from` | |--------|--------------|----------------|----------------| | **Words Fade In** | `opacity: 0` | 0.08s | start | | **Words Slide Up** | `opacity: 0, y: 20` | 0.10s | start | | **Words Slide Down** | `opacity: 0, y: -20` | 0.10s | start | | **Words From Left** | `opacity: 0, x: -30` | 0.08s | start | | **Words From Right** | `opacity: 0, x: 30` | 0.08s | start | | **Words From Center** | `opacity: 0, scale: 0.5` | 0.10s | center | | **Words From Edges** | `opacity: 0` | 0.10s | edges | | **Words Blur In** | `opacity: 0, filter: "blur(10px)"` | 0.10s | start | | **Words Skew In** | `opacity: 0, skewX: 20` | 0.08s | start | | **Words Typewriter** | `opacity: 0, x: -10` | 0.15s | start | ### Characters | Preset | `from` values | Stagger `each` | Stagger `from` | |--------|--------------|----------------|----------------| | **Chars Fade In** | `opacity: 0` | 0.03s | start | | **Chars Cascade** | `opacity: 0, y: 30` | 0.03s | start | | **Chars Pop In** | `scale: 0` | 0.02s | start | | **Chars Rotate In** | `opacity: 0, rotate: 90` | 0.03s | start | | **Chars Random** | `opacity: 0, y: 20` | 0.02s | random | | **Chars 3D Flip** | `rotateX: 90, opacity: 0` | 0.03s | start | | **Chars Wave** | `opacity: 0, y: -20` | 0.02s | start | | **Typewriter** | `opacity: 0` | 0.05s | start | ### Lines | Preset | `from` values | Stagger `each` | Stagger `from` | |--------|--------------|----------------|----------------| | **Lines Rise** | `opacity: 0, y: 40` | 0.20s | start | | **Lines Fade** | `opacity: 0` | 0.15s | start | All `x`/`y` values are in `px`. `rotate`/`rotateX` in degrees. `skewX` in degrees. ```typescript // Words Slide Up Motion("headline", "h1", { split: "words", from: { opacity: 0, y: 20 }, stagger: { each: 0.1, from: "start" }, duration: 0.7, ease: "power2.out", }).onPageLoad(); // Chars 3D Flip — perspective helps the 3D effect read clearly Motion("title-3d", ".title", { split: "chars", from: { rotateX: 90, opacity: 0 }, stagger: { each: 0.03, from: "start" }, duration: 0.6, ease: "power3.out", }).onPageLoad(); // Words From Center — stagger radiates outward from middle Motion("center-reveal", ".tagline", { split: "words", from: { opacity: 0, scale: 0.5 }, stagger: { each: 0.1, from: "center" }, duration: 0.6, ease: "back.out(1.7)", }).onPageLoad(); // Lines Rise — use mask to clip overflow during slide Motion("lines-in", ".paragraph", { split: "lines", mask: true, from: { opacity: 0, y: 40 }, stagger: { each: 0.2, from: "start" }, duration: 0.8, ease: "power3.out", }).onPageLoad(); ``` --- ## Smooth Transitions **Smooth Transition** presets use subtle values for gentle, polished reveals. All use `from` values only. Good for body copy, cards, and supporting UI elements where dramatic entrances feel excessive. | Preset | `from` values | |--------|--------------| | **Gentle Fade** | `opacity: 0.3` | | **Soft Slide Up** | `y: 20, opacity: 0.5` | | **Float In** | `y: 15, opacity: 0` | | **Blur to Focus** | `opacity: 0, filter: "blur(5px)"` | | **Elegant Scale** | `scale: 0.95, opacity: 0` | | **Drift Right** | `x: -20, opacity: 0` | | **Drift Left** | `x: 20, opacity: 0` | | **Rise Subtle** | `y: 10, opacity: 0, scale: 0.98` | | **Drop Subtle** | `y: -10, opacity: 0, scale: 0.98` | | **Grow Subtle** | `scale: 0.9, opacity: 0` | | **Shrink In** | `scale: 1.1, opacity: 0` | | **Soft Blur Slide** | `y: 15, opacity: 0, filter: "blur(3px)"` | `x`/`y` values are in `px`. Negative `opacity` start values (e.g. `0.3`, `0.5`) fade from partially visible rather than fully invisible — producing a softer effect. ```typescript // Elegant Scale — great for modals and cards Motion("card-reveal", ".card", { from: { scale: 0.95, opacity: 0 }, duration: 0.5, ease: "power2.out", }).onScroll({ scrub: false, toggleActions: "play none none none" }); // Blur to Focus — cinematic feel for hero text Motion("hero-focus", ".hero-text", { from: { opacity: 0, filter: "blur(5px)" }, duration: 0.9, ease: "power2.out", }).onPageLoad(); // Batch multiple elements with stagger Motion("section-items", ".section-item", { from: { y: 15, opacity: 0 }, duration: 0.5, stagger: 0.08, ease: "power2.out", }).onScroll({ scrub: false, toggleActions: "play none none none" }); ``` --- ## Creative Effects **Creative Effects** presets include entrance-only (`from`), exit-only (`to`), and complete transitions (`from` + `to` — where neither endpoint matches the element's natural state). ### Entrance (`from` only) | Preset | `from` values | |--------|--------------| | **Spiral In** | `scale: 0, rotate: 180, opacity: 0` | | **Twist In** | `rotate: 360, scale: 0, opacity: 0` | | **Bounce In** | `scale: 0` | | **Squeeze In** | `scaleX: 0, scaleY: 1, opacity: 0` | | **Morph In** | `scale: 0.5, skewX: 20, opacity: 0` | ### Exit (`to` only) | Preset | `to` values | |--------|------------| | **Spiral Out** | `scale: 0, rotate: -180, opacity: 0` | | **Squeeze Out** | `scaleX: 1, scaleY: 0, opacity: 0` | ### Complete Transitions (`from` + `to`) | Preset | `from` values | `to` values | |--------|--------------|------------| | **Slide Through** | `x: -100, opacity: 0` | `x: 100, opacity: 0` | | **Zoom Through** | `scale: 0, opacity: 0` | `scale: 2, opacity: 0` | | **Flip Full** | `rotateY: -90, opacity: 0` | `rotateY: 90, opacity: 0` | | **Rotate Through** | `rotate: -180` | `rotate: 180` | | **Color Shift** | `backgroundColor: "#ff0000"` | `backgroundColor: "#0000ff"` | | **Skew Transform** | `skewX: -30, x: -50` | `skewX: 30, x: 50` | | **Scale Bounce** | `scale: 0` | `scale: 1.2` + `repeat: { times: 1, yoyo: true }` | | **Perspective Spin** | `rotateY: 180, scale: 0.5` | `rotateY: 360, scale: 1` | `x` values are in `px`. `rotate`/`rotateY`/`skewX` in degrees. Complete transitions define explicit start and end states — the element passes through both on its way to the natural resting state (or is explicitly a pass-through effect like **Slide Through**). ```typescript // Bounce In — add a back ease for the overshoot feel Motion("pop-in", ".modal", { from: { scale: 0 }, duration: 0.5, ease: "back.out(1.7)", }).onPageLoad(); // Spiral Out — play on exit trigger Motion("spiral-exit", ".panel", { to: { scale: 0, rotate: -180, opacity: 0 }, duration: 0.6, ease: "power3.in", }).onClick(); // Flip Full — complete flip transition, both endpoints are non-natural Motion("flip-card", ".card", { from: { rotateY: -90, opacity: 0 }, to: { rotateY: 90, opacity: 0 }, duration: 0.6, ease: "power2.inOut", }).onClick(); // Color Shift — hover color transition between two explicit colors Motion("color-hover", ".button", { from: { backgroundColor: "#ff0000" }, to: { backgroundColor: "#0000ff" }, duration: 0.4, ease: "power2.inOut", }).onHover({ each: true, onLeave: "reverse" }); // Scale Bounce — grows in then bounces once Motion("scale-bounce", ".notification", { from: { scale: 0 }, to: { scale: 1.2 }, duration: 0.4, ease: "power2.out", repeat: { times: 1, yoyo: true }, }).onPageLoad(); // Perspective Spin — rotating reveal from a half-turn Motion("spin-reveal", ".card", { from: { rotateY: 180, scale: 0.5 }, to: { rotateY: 360, scale: 1 }, duration: 0.8, ease: "power2.out", }).onPageLoad(); ``` --- ## Quick Reference | Category | Count | Pattern | Trigger Style | |----------|-------|---------|---------------| | Entrances | 16 | `from` only | Page load, scroll | | Loops | 11 | `to` + `repeat` | Page load (always-on) | | Text Reveals | 20 | `from` + `split` + `stagger` | Page load, scroll | | Smooth Transitions | 12 | `from` only (subtle values) | Scroll, page load | | Creative Effects | 15 | `from`, `to`, or both | Page load, click, hover | **Total: 74 presets.** --- ## Translate > Move elements along X and Y axes with pixel or percentage values. URL: https://motion.page/docs/sdk/translate import LivePreview from "../../components/docs/blocks/LivePreview"; **Translate** moves elements along the X, Y, and Z axes. Properties `x` and `y` default to pixels; use `xPercent` and `yPercent` for responsive percentage-based movement that scales with element size. ## Basic Translate Pass `x` or `y` inside `from` or `to`. Numbers are treated as pixels by default. ```typescript import { Motion } from "@motion.page/sdk"; Motion("slide", ".box", { from: { x: -100 }, duration: 0.6, ease: "power2.out", }).play(); ```
1
2
3
`} css={`.box { width: 80px; height: 80px; background: #6633EE; border-radius: 12px; display:flex;align-items:center;justify-content:center;color:white;font-weight:bold;font-size:20px; }`} js={`import { Motion } from "https://cloud.motion.page/sdk/latest.js"; Motion("reveal", ".box", { from: { y: 60, opacity: 0 }, duration: 0.6, stagger: 0.12, ease: "power2.out", repeat: { times: -1, yoyo: true, delay: 0.6 }, }).play();`} /> ## Properties | Property | Type | Default | Description | |----------|------|---------|-------------| | `x` | `number` | `0` | Horizontal offset in pixels | | `y` | `number` | `0` | Vertical offset in pixels | | `z` | `number` | `0` | Depth offset in pixels (3D) | | `xPercent` | `number` | `0` | Horizontal offset as % of element width | | `yPercent` | `number` | `0` | Vertical offset as % of element height | --- ## Percentage Values `xPercent` and `yPercent` move an element relative to its own dimensions. A value of `-100` slides the element completely off-screen in its own width or height — ideal for full-bleed panel transitions. ```typescript Motion("responsive-slide", ".panel", { from: { yPercent: -100 }, duration: 0.8, ease: "power3.out", }).onPageLoad(); ``` --- ## From-Only Pattern for Reveals The most common translate pattern: start offset, animate to the natural position. The SDK reads the element's current CSS (`x: 0`, `y: 0`) as the `to` endpoint automatically — no need to declare it. ```typescript Motion("reveal", ".card", { from: { y: 100, opacity: 0 }, duration: 0.6, ease: "power2.out", }).onScroll({ scrub: false, toggleActions: "play none none none" }); ``` --- ## Combining X and Y — Diagonal Movement Set both `x` and `y` simultaneously to animate along a diagonal path. ```typescript Motion("diagonal", ".element", { from: { x: -80, y: 60, opacity: 0 }, duration: 0.7, ease: "power2.out", }).onScroll({ scrub: false, toggleActions: "play none none none" }); ``` --- ## px vs % — When to Use Each **Pixels** (`x`, `y`) — Fixed distance. Predictable across all screen sizes. Best for small UI shifts like hover lifts and nudges. **Percent** (`xPercent`, `yPercent`) — Relative to element dimensions. Responsive. Best for full-width slides and off-screen entrances. --- ## Common Patterns ### Slide Up Reveal ```typescript Motion("slide-up", ".section", { from: { y: 40, opacity: 0 }, duration: 0.6, ease: "power2.out", }).onScroll({ scrub: false, toggleActions: "play none none none" }); ``` ### Horizontal Scroll ```typescript const sections = Motion.utils.toArray(".panel"); Motion("h-scroll", ".track", { to: { xPercent: -100 * (sections.length - 1) }, duration: 1, }).onScroll({ scrub: true, pin: true, end: `+=${sections.length * 100}%`, }); ``` ### Parallax Offset Drive `y` with scroll scrub to produce a parallax effect. The element moves at a different speed than the page scroll. ```typescript Motion("parallax", ".bg-layer", { from: { y: -50 }, to: { y: 50 }, duration: 1, }).onScroll({ scrub: true }); ``` --- ## Z-Axis Translation (3D Depth) `z` moves elements along the depth axis — toward or away from the viewer. It requires a parent element with a `perspective` CSS property to produce a visible 3D effect. ```typescript Motion("depth", ".card", { from: { z: -200, opacity: 0 }, duration: 0.8, ease: "power2.out", }).play(); ``` Set `perspective: 800px` (or similar) on the parent container: ```css .container { perspective: 800px; } ``` For more on combining translate with rotations and 3D, see the [3D Transforms](/docs/sdk/3d-transforms) page. --- ## Related - [Opacity](/docs/sdk/opacity) — fade combined with translate for polished reveals - [Scale](/docs/sdk/scale) — zoom effects often paired with translate - [3D Transforms](/docs/sdk/3d-transforms) — rotateX, rotateY, and perspective - [Transform Origin](/docs/sdk/transform-origin) — control the anchor point for all transforms --- ## Breakpoints > Configure responsive breakpoints and per-breakpoint animation behavior. URL: https://motion.page/docs/sdk/breakpoints import CodeBlock from "../../components/docs/blocks/CodeBlock.astro"; import LivePreview from "../../components/docs/blocks/LivePreview"; Motion.page uses four named breakpoint ranges — **phones**, **tablets**, **laptops**, and **desktop** — with configurable pixel thresholds. Use `Motion.responsive()` when variants should swap live, or native `matchMedia` for a one-time conditional guard. The Builder generates the appropriate responsive code automatically. --- ## Default Breakpoint Values These are the global defaults. All SDK examples on this page use them. You can change the thresholds in **Builder > Settings > Breakpoints**. | Range | Condition | Default threshold | |-------|-----------|-------------------| | Phones | `max-width` | `576px` | | Tablets | `max-width` | `768px` | | Laptops | `max-width` | `992px` | | Desktop | `min-width` | `993px` | Each threshold is a **maximum width** for the named range. Desktop has no upper bound — it applies from `993px` upward. --- ## SDK: matchMedia Guard Use `window.matchMedia()` when a single animation only needs to be registered at one size. The check runs once at script execution time — if the condition doesn't match, the animation is never registered. For variants that react to resize, use [`Motion.responsive()`](/docs/sdk/responsive). ```typescript import { Motion } from "@motion.page/sdk"; // Phones only — up to 576px if (matchMedia("screen and (max-width: 576px)").matches) { Motion("mobile-hero", ".hero", { from: { opacity: 0, y: 20 }, duration: 0.5, }).onPageLoad(); } ```
Preview viewport
Resize the docs window, then use Re-run.
≤ 576Phone
577–768Tablet
769–992Laptop
≥ 993Desktop
`} css={`.breakpoint-demo { width: min(720px, 100%); padding: 20px; border: 1px solid rgba(143, 158, 255, 0.22); border-radius: 20px; background: linear-gradient(145deg, rgba(25, 30, 61, 0.94), rgba(7, 10, 27, 0.96)); } .breakpoint-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin-bottom: 20px; } .breakpoint-heading div { display: grid; gap: 3px; } .breakpoint-heading span, .breakpoint-heading small { color: #929abd; font-size: 12px; } .viewport-width { color: #f4f5ff; font-size: 26px; } .range-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; } .range-card { min-height: 100px; padding: 14px; border: 1px solid rgba(153, 168, 255, 0.18); border-radius: 14px; background: rgba(9, 13, 34, 0.78); display: flex; flex-direction: column; justify-content: space-between; } .range-card span { color: #8f98bd; font-size: 11px; } .range-card strong { font-size: 14px; } .range-card.is-active { border-color: #8c6cff; background: linear-gradient(145deg, rgba(111, 70, 235, 0.72), rgba(47, 35, 105, 0.88)); box-shadow: 0 16px 42px rgba(91, 64, 216, 0.28); } @media (max-width: 520px) { .breakpoint-heading { align-items: flex-start; flex-direction: column; } .range-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .range-card { min-height: 72px; } }`} js={`import { Motion } from "https://cloud.motion.page/sdk/latest.js"; const ranges = [ { name: "phone", query: "screen and (max-width: 576px)", from: { opacity: 0.35, y: 20 } }, { name: "tablet", query: "screen and (min-width: 577px) and (max-width: 768px)", from: { opacity: 0.35, scale: 0.92 } }, { name: "laptop", query: "screen and (min-width: 769px) and (max-width: 992px)", from: { opacity: 0.35, x: -20 } }, { name: "desktop", query: "screen and (min-width: 993px)", from: { opacity: 0.35, x: 20 } }, ]; document.querySelector(".viewport-width").textContent = window.innerWidth + "px"; const active = ranges.find((range) => matchMedia(range.query).matches); if (active) { const card = document.querySelector("[data-tier='" + active.name + "']"); card.classList.add("is-active"); Motion("matched-breakpoint", card, { from: active.from, duration: 0.8, ease: "power2.out", repeat: { times: -1, yoyo: true, delay: 0.6 }, }).play(); }`} /> --- ## Per-Range Examples ### Phones only ```typescript import { Motion } from "@motion.page/sdk"; if (matchMedia("screen and (max-width: 576px)").matches) { Motion("mobile-reveal", ".section-title", { from: { opacity: 0, y: 24 }, duration: 0.5, ease: "power2.out", }).onPageLoad(); } ``` ### Tablets only Tablets span from just above the phones threshold to the tablets threshold: ```typescript import { Motion } from "@motion.page/sdk"; if (matchMedia("screen and (min-width: 577px) and (max-width: 768px)").matches) { Motion("tablet-grid", ".card", { from: { opacity: 0, scale: 0.95 }, duration: 0.4, stagger: 0.06, ease: "power2.out", }).onScroll({ toggleActions: "play none none none" }); } ``` ### Laptops only ```typescript import { Motion } from "@motion.page/sdk"; if (matchMedia("screen and (min-width: 769px) and (max-width: 992px)").matches) { Motion("laptop-parallax", ".bg-shape", { from: { y: -40 }, to: { y: 40 }, duration: 1, }).onScroll({ scrub: true }); } ``` ### Desktop only ```typescript import { Motion } from "@motion.page/sdk"; // Skip on mobile and tablet — hover effects don't suit touch screens if (matchMedia("screen and (min-width: 993px)").matches) { 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" }); } ``` ### Phones and tablets (up to 768px) Combine ranges by using the upper threshold of the outermost range: ```typescript import { Motion } from "@motion.page/sdk"; if (matchMedia("screen and (max-width: 768px)").matches) { Motion("small-screen-fade", ".hero-image", { from: { opacity: 0 }, duration: 0.6, }).onPageLoad(); } ``` ### Tablets, laptops, and desktop (min-width 577px) ```typescript import { Motion } from "@motion.page/sdk"; if (matchMedia("screen and (min-width: 577px)").matches) { Motion("wide-parallax", ".feature-block", { from: { x: -60, opacity: 0 }, duration: 0.8, stagger: 0.1, ease: "power3.out", }).onScroll({ toggleActions: "play none none none" }); } ``` --- ## Different Animations per Breakpoint Use multiple `matchMedia` checks to run different animations at different sizes. Only one block runs per page load. ```typescript import { Motion } from "@motion.page/sdk"; // Desktop: full parallax if (matchMedia("screen and (min-width: 993px)").matches) { Motion("hero-effect", ".hero-image", { from: { y: -60 }, to: { y: 60 }, duration: 1, }).onScroll({ scrub: true }); } // Tablets and laptops: simple fade on scroll if ( matchMedia("screen and (min-width: 577px) and (max-width: 992px)").matches ) { Motion("hero-effect", ".hero-image", { from: { opacity: 0 }, duration: 0.7, ease: "power2.out", }).onScroll({ toggleActions: "play none none none" }); } // Phones: instant fade on load — skip scroll complexity if (matchMedia("screen and (max-width: 576px)").matches) { Motion("hero-effect", ".hero-image", { from: { opacity: 0 }, duration: 0.5, }).onPageLoad(); } ``` --- ## Builder: Enabled Breakpoints In the Builder, each timeline has an **Enabled Breakpoints** range selector in the Trigger Section. It maps to a five-point scale: | Point | Icon | Range | |-------|------|-------| | 1 | ⊘ (disabled) | Below phones — animation off | | 2 | Phone | Phones (`max-width: 576px`) | | 3 | Tablet | Tablets (`max-width: 768px`) | | 4 | Laptop | Laptops (`max-width: 992px`) | | 5 | ∞ | Desktop and above (no upper bound) | The default range is **1 → 5** (all devices). Drag either handle inward to restrict the animation to a narrower range. The Builder wraps the generated code automatically: ```typescript // Range set to phones + tablets [1–3] generates: if (matchMedia("screen and (max-width: 768px)").matches) { // your animation code } // Range set to desktop only [4–5] generates: if (matchMedia("screen and (min-width: 992px)").matches) { // your animation code } ``` No manual code is needed when using the Builder — the `matchMedia` guard is injected at export time. --- ## Custom Breakpoint Thresholds The pixel values for phones, tablets, and laptops are global settings. Change them in **Builder > Settings > Breakpoints**. All timelines and generated SDK code will reflect the updated thresholds. | Setting | Tooltip | Default | |---------|---------|---------| | Phones | Maximum width in pixels | `576` | | Tablets | Maximum width in pixels | `768` | | Laptops | Maximum width in pixels | `992` | After updating these values, the breakpoint reference table above changes accordingly. Update your SDK `matchMedia` calls to match if you override the defaults. --- ## Resize-Aware Breakpoints `matchMedia` checks run once at load. If a user resizes their browser past a threshold after the page has loaded, registered animations don't update. For resize-aware behavior, use `Motion.context()` with a `MediaQueryList` change listener: ```typescript import { Motion } from "@motion.page/sdk"; const mq = matchMedia("screen and (min-width: 993px)"); let ctx = Motion.context(() => { if (mq.matches) { Motion("desktop-scroll", ".card", { from: { opacity: 0, y: 40 }, duration: 0.6, stagger: 0.1, }).onScroll({ toggleActions: "play none none none" }); } }); mq.addEventListener("change", () => { ctx.revert(); ctx = Motion.context(() => { if (mq.matches) { Motion("desktop-scroll", ".card", { from: { opacity: 0, y: 40 }, duration: 0.6, stagger: 0.1, }).onScroll({ toggleActions: "play none none none" }); } }); }); ``` `ctx.revert()` kills all timelines in the context and restores initial CSS. Re-running `Motion.context()` reinitializes them with fresh selectors. For most sites, a one-time check at load is sufficient — resize-aware handling is only needed when your layout shifts significantly on breakpoint crossing. --- ## Related - [Responsive Animations](/docs/sdk/responsive) — matchMedia patterns, reduced motion, and SPA-aware reinit - [Core Concepts](/docs/sdk/core-concepts) — `Motion.context()` for lifecycle management and teardown - [Page Load Trigger](/docs/sdk/page-load) — entrance animations on load - [Scroll Trigger](/docs/sdk/scroll-trigger) — viewport-based scroll animations --- ## Duration & Delay > Control animation timing with duration in seconds and delay before start. URL: https://motion.page/docs/sdk/duration-delay import LivePreview from "../../components/docs/blocks/LivePreview"; `duration` and `delay` are the two core timing properties in every `AnimationConfig`. Duration controls how long the animation runs; delay controls when it starts. ## Duration **`duration`** sets how many seconds the animation takes to complete. It accepts any positive number, including decimals. | Property | Type | Default | Description | |----------|------|---------|-------------| | `duration` | `number` | `0.5` | Seconds the animation takes to complete | ```typescript import { Motion } from "@motion.page/sdk"; // 0.3s — fast UI interaction Motion("quick-fade", ".button", { from: { opacity: 0 }, duration: 0.3, }).play(); // 1.2s — slower scroll reveal Motion("slow-reveal", ".hero", { from: { opacity: 0, y: 60 }, duration: 1.2, ease: "power2.out", }).onScroll({ scrub: false, toggleActions: "play none none none" }); ``` ### Duration 0 — Instant Snap Setting `duration: 0` makes the animation apply instantly with no transition. This is functionally equivalent to `Motion.set()`. ```typescript // These two are equivalent Motion("snap", ".box", { to: { opacity: 0 }, duration: 0, }).play(); Motion.set(".box", { opacity: 0 }); ``` Prefer `Motion.set()` when the intent is clearly "apply immediately" — it's more readable. Use `duration: 0` when you want snap behaviour inside a multi-step timeline where other entries have duration. ## Delay **`delay`** sets how many seconds to wait before the animation begins. The element holds its initial state during the delay period. | Property | Type | Default | Description | |----------|------|---------|-------------| | `delay` | `number` | `0` | Seconds to wait before the animation starts | ```typescript Motion("delayed-entry", ".card", { from: { opacity: 0, y: 30 }, duration: 0.6, delay: 0.4, ease: "power2.out", }).play(); ``` ## Combining Duration & Delay Use both together to fine-tune when and how fast an animation runs: ```typescript Motion("entrance", ".modal", { from: { opacity: 0, scale: 0.92 }, duration: 0.5, delay: 0.2, ease: "power2.out", }).onPageLoad(); ```
`} css={`.scene { display: flex; align-items: center; justify-content: center; height: 100%; } .box { width: 80px; height: 80px; background: #6633EE; border-radius: 12px; }`} js={`import { Motion } from "https://cloud.motion.page/sdk/latest.js"; Motion("appear", ".box", { from: { opacity: 0, scale: 0.92 }, duration: 0.5, delay: 0.2, ease: "power2.out", repeat: { times: -1, yoyo: true, delay: 1.8 }, }).play();`} /> ## Delay with Stagger When `stagger` is used, the per-element delay is added **on top of** the base `delay`. The first element starts at `delay`, the second at `delay + stagger`, the third at `delay + 2 × stagger`, and so on. ```typescript // Element 1 starts at 0.3s // Element 2 starts at 0.3 + 0.08 = 0.38s // Element 3 starts at 0.3 + 0.16 = 0.46s // …and so on Motion("list-reveal", ".item", { from: { opacity: 0, y: 20 }, duration: 0.5, delay: 0.3, stagger: 0.08, ease: "power2.out", }).onScroll({ scrub: false, toggleActions: "play none none none" }); ``` `delay` is useful here as a **scene delay** — a buffer before the staggered sequence begins, so it doesn't fire the instant the trigger activates. See [Stagger](/docs/sdk/stagger) for the full `StaggerVars` API and advanced ordering options. ## API Reference ```typescript interface AnimationConfig { duration?: number; // Seconds — how long the animation runs delay?: number; // Seconds — how long to wait before starting stagger?: number | StaggerVars; // Per-element delay, stacks on top of delay // ...other props } ``` ## Timing Tips **UI interactions** (hovers, clicks, toggles) — `0.2–0.4s`. Fast enough to feel responsive, slow enough to be visible. **Standard transitions** (modals, panels, toasts) — `0.4–0.6s`. Balanced and polished. **Scroll reveals and entrance animations** — `0.6–1.2s`. Longer reads as deliberate and cinematic. **Stagger increments** — `0.02–0.08s` per element. Below `0.02s` is imperceptible; above `0.12s` starts feeling slow for most lists. ## Common Mistakes **Using milliseconds instead of seconds.** Duration is always in seconds. `duration: 500` means 500 seconds, not 500ms. Use `duration: 0.5` for half a second. **Forgetting that delay stacks with stagger.** With `delay: 0.5` and `stagger: 0.1`, the last element in a 10-item list starts at `0.5 + 0.9 = 1.4s`. Factor this in when the animation needs to complete within a tight time window. --- Related: [Easing](/docs/sdk/easing) · [Stagger](/docs/sdk/stagger) · [Core Concepts](/docs/sdk/core-concepts) --- ## Presentation Mode > Build a gesture-driven section deck with the public SDK and understand the Builder-generated presentation output. URL: https://motion.page/docs/sdk/presentation-mode import CodeBlock from "../../components/docs/blocks/CodeBlock.astro"; import LivePreview from "../../components/docs/blocks/LivePreview"; **Presentation Mode** is a Motion.page Builder feature that exports a section navigator composed from public SDK primitives. In hand-written SDK code, build the same experience with ordinary `Motion()` timelines, `Motion.set()`, and `.onGesture()`. ## Public SDK Boundary The public `GestureConfig` does **not** include a `presentation` property. Its required fields are `types` and `events`; optional fields include `target`, `tolerance`, `dragMinimum`, `wheelSpeed`, `preventDefault`, `each`, and the other options documented in [Observer & Gesture](/docs/sdk/observer-gesture). ```typescript // The navigator below patches this timeline's play() and reverse() methods. Motion("deck-gesture").onGesture({ target: ".deck", types: ["wheel", "touch", "pointer"], events: { Down: "play", Up: "reverse" }, preventDefault: true, }); ``` The SDK generator has a separate internal `GestureOptions` type that temporarily adds `presentation`. The Builder stores its Presentation Mode controls there, then consumes them to generate normal SDK code. That generator-only metadata is never passed to `.onGesture()`. --- ## How Builder Output Works The generated presentation script: 1. Queries the configured section selector and stacks every section absolutely. 2. Creates an independent timeline for each section's configured animations. 3. Tracks `currentIndex` and an `animating` guard inside `gotoSection(index, direction)`. 4. Builds reusable outgoing and incoming transition timelines. 5. Registers a public `.onGesture()` trigger whose `play` and `reverse` actions call `gotoSection()`. 6. Exposes the generated navigator on `window` for absolute-index navigation. The emitted gesture wiring uses `play` and `reverse`. It does not use `playNext` or `playPrevious`, because those actions require SDK `each`-mode sibling instances; the generator manages its own section array instead. --- ## Complete Hand-Written Example The preview below implements the same architecture directly with the public SDK. Scroll or drag inside it, or use the Previous and Next buttons.
Section 1Scroll or drag to navigate
Section 2Built from public SDK methods
Section 3No presentation config required
`} css={`.deck { position: relative; width: 100%; height: calc(100vh - 2rem); min-height: 280px; overflow: hidden; border-radius: 18px; font-family: system-ui, sans-serif; } .slide { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; color: white; } .slide span { font-size: 26px; font-weight: 750; } .slide small { color: rgba(255, 255, 255, 0.62); font-size: 12px; } .s1 { background: linear-gradient(135deg, #1a0533, #6633ee); } .s2 { background: linear-gradient(135deg, #071b32, #1266a8); } .s3 { background: linear-gradient(135deg, #0a2616, #16844a); } .deck-nav { position: absolute; z-index: 10; right: 18px; bottom: 18px; display: flex; gap: 8px; } .deck-nav button { padding: 7px 11px; border: 1px solid rgba(255, 255, 255, 0.24); border-radius: 8px; background: rgba(4, 6, 20, 0.32); color: white; font: 600 11px/1 system-ui; }`} js={`import { Motion } from "https://cloud.motion.page/sdk/latest.js"; const sections = Motion.utils.toArray(".slide"); let currentIndex = 0; let animating = false; sections.forEach((section, index) => { Motion.set(section, { opacity: index === 0 ? 1 : 0, visibility: index === 0 ? "visible" : "hidden", zIndex: index === 0 ? 1 : 0, }); }); function gotoSection(index, direction) { index = (index + sections.length) % sections.length; if (animating || index === currentIndex) return; animating = true; const outgoing = sections[currentIndex]; const incoming = sections[index]; Motion.get("deck-out")?.kill(false); Motion.get("deck-in")?.kill(false); Motion.set(outgoing, { zIndex: 1 }); Motion.set(incoming, { opacity: 0, y: direction * 100 + "%", visibility: "visible", zIndex: 2, }); Motion("deck-out", outgoing, { to: { opacity: 0, y: direction * -100 + "%" }, duration: 0.65, ease: "power2.inOut", }).play(); Motion("deck-in", incoming, { to: { opacity: 1, y: 0 }, duration: 0.65, ease: "power2.inOut", }).onComplete(() => { Motion.set(outgoing, { visibility: "hidden", zIndex: 0 }); Motion.set(incoming, { zIndex: 1 }); setTimeout(() => { animating = false; }, 350); }).play(); currentIndex = index; } const gesture = Motion("deck-gesture"); gesture.onGesture({ target: ".deck", types: ["wheel", "touch", "pointer"], events: { Down: "play", Up: "reverse" }, tolerance: 25, dragMinimum: 35, preventDefault: true, }); gesture.play = function () { gotoSection(currentIndex + 1, 1); return gesture; }; gesture.reverse = function () { gotoSection(currentIndex - 1, -1); return gesture; }; document.querySelector('[data-direction="next"]').addEventListener("click", () => gesture.play()); document.querySelector('[data-direction="previous"]').addEventListener("click", () => gesture.reverse());`} /> --- ## Builder Generator Settings These settings belong to the Builder and SDK generator, **not** to public `GestureConfig`: | Generator setting | Type | Default | Generated behavior | |-------------------|------|---------|--------------------| | `sectionSelector` | `string` | `'.section'` | Selects the sections to stack. The Builder derives it from the Observer target. | | `direction` | `'vertical' \| 'horizontal'` | `'vertical'` | Selects the `y` or `x` transition axis. | | `duration` | `number` | `1` | Sets each incoming/outgoing transition duration in seconds. The Builder constrains it to `0.1–5`. | | `cooldown` | `number` | `0.5` | Keeps navigation locked for this many additional seconds after the transition. The Builder constrains it to `0.1–5`. | | `effects` | `('opacity' \| 'translate')[]` | both | Chooses whether generated transitions fade, translate, or do both. | | `infiniteRepeat` | `boolean` | `false` | Wraps out-of-range indices to the opposite end. | | `reverseGesture` | `boolean` | `false` | Reverses the generated forward/backward event mapping. | | `useVerticalWheel` | `boolean` | `false` | Adds `Down`/`Up` mappings to a horizontal deck. | | `initialSection` | `number` | `0` | Chooses the initially visible zero-based section. This currently remains generator data rather than a visible Builder control. | Do not copy these keys into `.onGesture()`. In hand-written code they become ordinary variables and branches inside your own navigator. --- ## Direction and Gesture Mapping The Builder generator maps direction settings to public gesture events: | Builder setting | Forward action | Backward action | |-----------------|----------------|-----------------| | Vertical | `Down → play` | `Up → reverse` | | Horizontal | `Right → play` | `Left → reverse` | | Horizontal + Vertical Wheel | Also `Down → play` | Also `Up → reverse` | **Reverse Gesture** swaps every forward/backward pair. A hand-written implementation can use the same event map or choose its own. --- ## Transition Effects The Builder's **Opacity** and **Translate** selections decide which properties the generator puts into its transition timelines: | Builder selection | Outgoing section | Incoming section | |-------------------|------------------|------------------| | Opacity + Translate | Fades and moves off-screen | Fades and moves in from off-screen | | Translate only | Moves off-screen | Moves in from off-screen | | Opacity only | Fades out in place | Fades in place | If no effects are selected in the Builder, the adapter restores both defaults. In hand-written SDK code, omit whichever properties you do not want to animate: ```typescript // Fade-only transition inside your gotoSection() function Motion.get("deck-out")?.kill(false); Motion.get("deck-in")?.kill(false); Motion.set(incoming, { opacity: 0, visibility: "visible" }); Motion("deck-out", outgoing, { to: { opacity: 0 }, duration: 0.8, }).play(); Motion("deck-in", incoming, { from: { opacity: 0 }, to: { opacity: 1 }, duration: 0.8, }).play(); ``` --- ## Hand-Written Variants ### Horizontal Deck Animate `x` instead of `y`, then map horizontal events. Add `Down` and `Up` only when you want a vertical mouse wheel to control the horizontal deck: ```typescript gesture.onGesture({ target: ".deck", types: ["wheel", "touch", "pointer"], events: { Right: "play", Left: "reverse", Down: "play", Up: "reverse", }, preventDefault: true, }); ``` ### Infinite Loop Normalize the requested absolute index before checking it: ```typescript index = ((index % sections.length) + sections.length) % sections.length; ``` Without that line, return early when `index < 0 || index >= sections.length`. ### Per-Section Animations The Builder generator scopes each animation target to the current section. Reproduce that explicitly by resolving descendants from each section element: Motion("deck-section-" + index, section.querySelectorAll("[data-reveal]"), { from: { opacity: 0, y: 40 }, duration: 0.7, stagger: 0.08, ease: "power2.out", }) ); // Inside gotoSection(): sectionTimelines[currentIndex]?.reverse(); sectionTimelines[index]?.play();`} /> When the configured animation target equals the section selector itself, the generator targets the section element directly. Otherwise it uses `section.querySelectorAll(animationTarget)`. --- ## Builder-Generated External Navigation Generated presentation code exposes its navigator as `window.__mp_presentation_NAME`. This is a generator convenience, not a public SDK method. The name is derived from the Builder timeline name and sanitized for use as a property. `gotoSection(index, direction)` expects an **absolute zero-based index** and a transition direction (`1` for forward, `-1` for backward): ```typescript // Timeline name: "deck" window.__mp_presentation_deck?.gotoSection(2, 1); window.__mp_presentation_deck?.gotoSection(0, -1); ``` The function uses the same `animating` guard as gesture navigation, so calls made during the transition or cooldown are ignored. It is not a relative next/previous API. --- ## Builder Usage In the Motion.page Builder, open **Left Panel → Trigger → Observer**, enable **Trigger each iteration individually**, then enable **Presentation mode (full page sections)**. The first toggle is a UI prerequisite that reveals Presentation Mode; generated `.onGesture()` code still uses the generator-owned section array rather than public `each` mode. | Builder control | What the generator changes | |-----------------|----------------------------| | Presentation Mode | Selects the standalone section-navigator generator path. | | Observer target | Becomes the section selector. | | Direction | Chooses `x` or `y` transitions and directional event mappings. | | Transition Duration | Sets incoming and outgoing timeline durations. | | Gesture Cooldown | Extends the navigation lock after each transition. | | Effects | Includes opacity, translation, or both in generated timelines. | | Infinite Repeat | Enables modulo wrapping. | | Reverse Gesture | Swaps forward and backward event mappings. | | Trigger on Vertical Scroll | Adds vertical-wheel mappings in horizontal mode. | --- ## Tips and Gotchas **Give the container an explicit height.** Generated sections use absolute positioning with `width: 100%` and `height: 100%`; their containing block must establish a height, commonly `100vh`. **Cooldown starts after the transition.** Navigation remains locked for the transition duration and then for the configured cooldown. **Builder timing settings are generator metadata.** `duration` and `cooldown` here are not fields on public `GestureConfig`; public `stopDelay` is a separate gesture option. **Vertical wheel mapping is horizontal-only.** A vertical deck already maps `Down` and `Up`. **Clean up hand-written decks explicitly.** Kill the transition, section, and gesture timelines and restore the inline section styles when unmounting a component or leaving an SPA route. --- ## Related - [Observer & Gesture](/docs/sdk/observer-gesture) — public `GestureConfig` and gesture actions - [Timeline Control](/docs/sdk/timeline-control) — manually play and reverse section timelines - [SPA Integration](/docs/sdk/spa-integration) — scope and clean up hand-written navigators - [Presentation Mode — Builder](/docs/builder/presentation-mode) — configure the generator visually --- ## Advanced Targeting > Target specific pages with post types, custom RegEx URL patterns, and selector strategies. URL: https://motion.page/docs/sdk/advanced-targeting import LivePreview from "../../components/docs/blocks/LivePreview"; Targeting controls **which elements** animate and **which pages** an animation runs on. This page covers compound CSS selectors, scoped targeting, dynamic elements, URL-based page matching, and WordPress post type targeting. --- ## CSS Selector Strategies The SDK accepts any valid CSS selector string as a `TargetInput`. Complex selectors let you target elements precisely without adding extra classes to your HTML. ### Compound Selectors Combine multiple selector rules to narrow the target: ```typescript import { Motion } from "@motion.page/sdk"; // Elements that have BOTH classes Motion("combined", ".card.featured", { from: { opacity: 0, y: 30 }, duration: 0.6, }).onPageLoad(); // Direct children only Motion("children", ".menu > .menu-item", { from: { x: -20, opacity: 0 }, stagger: 0.06, duration: 0.4, }).onPageLoad(); // Adjacent sibling Motion("sibling", ".hero + .intro", { from: { opacity: 0 }, duration: 0.8, }).onPageLoad(); ```
.card.featured
card
.menu > .menu-item
.hero + .intro
hero
adjacent intro
gap
other intro
`} css={`.selector-demo { width: min(620px, 100%); display: grid; gap: 12px; } .demo-row { display: grid; grid-template-columns: 150px 1fr; align-items: center; gap: 16px; padding: 14px; border: 1px solid rgba(153, 102, 255, 0.25); border-radius: 14px; background: rgba(255, 255, 255, 0.035); } code { color: #bda9ff; font-size: 12px; } .targets { display: flex; align-items: center; gap: 8px; min-width: 0; } .card, .menu-item, .hero, .intro, .gap { padding: 9px 11px; border: 1px solid rgba(255, 255, 255, 0.14); border-radius: 9px; background: #171b33; color: #dfe1f4; font-size: 11px; white-space: nowrap; } .featured { border-color: #8f6cff; } .menu-group { padding: 5px; border: 1px dashed rgba(255, 255, 255, 0.18); border-radius: 11px; } .sequence { gap: 5px; } .sequence > * { padding-inline: 8px; } .gap { color: #7b819e; border-style: dashed; } @media (max-width: 520px) { .demo-row { grid-template-columns: 112px 1fr; gap: 10px; padding: 11px; } .targets { flex-wrap: wrap; } }`} js={`import { Motion } from "https://cloud.motion.page/sdk/latest.js"; Motion("compound-selector-demo", ".card.featured", { to: { scale: 1.08, backgroundColor: "#6f4ee8" }, duration: 0.55, ease: "power2.inOut", repeat: { times: -1, yoyo: true, delay: 0.6 }, }).play(); Motion("direct-child-demo", ".menu > .menu-item", { from: { opacity: 0.3, x: -18 }, duration: 0.55, ease: "power2.out", repeat: { times: -1, yoyo: true, delay: 0.6 }, }).play(); Motion("adjacent-sibling-demo", ".hero + .intro", { from: { opacity: 0.3, y: 12 }, duration: 0.55, ease: "power2.out", repeat: { times: -1, yoyo: true, delay: 0.6 }, }).play();`} /> ### `:nth-child` and Structural Selectors Target elements by their position in the DOM: ```typescript // Every other card — skip the first Motion("even-cards", ".grid .card:nth-child(even)", { from: { y: 40, opacity: 0 }, duration: 0.5, stagger: 0.07, }).onScroll({ each: true }); // First three items only Motion("first-three", ".list-item:nth-child(-n+3)", { from: { opacity: 0, x: -20 }, duration: 0.5, stagger: 0.08, }).onPageLoad(); // Last item in a group Motion("last-item", ".nav-item:last-child", { to: { color: "#9966FF" }, duration: 0.3, }).onHover({ onLeave: "reverse" }); ``` ### Data Attribute Selectors Target elements by their HTML data attributes — useful when you control the markup but not the class names: ```typescript // Any element with the attribute present Motion("data-reveal", "[data-animate]", { from: { opacity: 0, y: 20 }, duration: 0.5, stagger: 0.06, }).onScroll({ each: true }); // Attribute with a specific value Motion("data-hero", "[data-role='hero']", { from: { scale: 0.95, opacity: 0 }, duration: 0.8, ease: "power3.out", }).onPageLoad(); // Attribute starting with a prefix — useful for BEM modifiers Motion("data-prefix", "[data-speed^='fast']", { from: { y: 30, opacity: 0 }, duration: 0.3, }).onPageLoad(); ``` ### Selector Arrays Pass multiple selectors to animate them as one group: ```typescript // All three animate together as a single timeline Motion("multi", ["h1", ".subtitle", ".cta-button"], { from: { opacity: 0, y: 30 }, duration: 0.6, stagger: 0.1, }).onPageLoad(); ``` --- ## Scoped Targeting `Motion.utils.toArray(target, scope)` resolves a selector only within a specific container. Use this when your page has repeated components with identical class names and you need to target only the one inside a certain parent. ```typescript import { Motion } from "@motion.page/sdk"; // Resolve .card only inside #featured-section const cards = Motion.utils.toArray(".card", "#featured-section"); Motion("scoped-cards", cards, { from: { opacity: 0, y: 30 }, duration: 0.6, stagger: 0.08, }).onPageLoad(); ``` This prevents animations from leaking into other sections that share the same class names. ### Scoping with Refs (React) In React, pass the ref element as the scope to avoid stale global selectors: ```tsx import { useEffect, useRef } from "react"; import { Motion } from "@motion.page/sdk"; export function ProductGrid() { const gridRef = useRef(null); useEffect(() => { if (!gridRef.current) return; const cards = Motion.utils.toArray(".card", gridRef.current); Motion("grid-reveal", cards, { from: { opacity: 0, y: 24 }, duration: 0.5, stagger: 0.07, ease: "power2.out", }).onScroll({ each: true }); return () => Motion.kill("grid-reveal"); }, []); return (
{/* cards rendered here */}
); } ``` --- ## Dynamic Element Targeting Elements added after the page loads — via AJAX, pagination, or framework rendering — won't be found by selectors that ran at init time. Use `Motion.context()` to scope animations so they can be reinitialized cleanly when the DOM changes. ### Motion.context() for Reinit ```typescript import { Motion } from "@motion.page/sdk"; let ctx = Motion.context(() => { Motion("cards", ".card", { from: { opacity: 0, y: 30 }, duration: 0.5, stagger: 0.07, }).onScroll({ each: true }); }); // After AJAX inserts new .card elements: ctx.refresh(); // kills old animations, re-runs init, re-resolves .card ``` `ctx.refresh()` destroys the previous timelines and re-runs the factory function. All selectors are evaluated again against the updated DOM. ### MutationObserver Pattern Use a `MutationObserver` to detect when new elements appear and reinitialize: ```typescript import { Motion } from "@motion.page/sdk"; let ctx = Motion.context(() => { Motion("dynamic-items", "[data-animate]", { from: { opacity: 0, y: 20 }, duration: 0.4, stagger: 0.06, }).onPageLoad(); }); const observer = new MutationObserver(() => { ctx.refresh(); }); // Watch for added nodes anywhere in the list container observer.observe(document.querySelector("#results-list")!, { childList: true, subtree: true, }); // Clean up when the page unloads window.addEventListener("pagehide", () => { observer.disconnect(); ctx.revert(); }); ``` > **Performance tip:** Debounce the observer callback if the DOM updates frequently in rapid bursts. Call `ctx.refresh()` once after the burst settles rather than on every mutation. ### Debounced Observer ```typescript import { Motion } from "@motion.page/sdk"; let ctx = Motion.context(() => { Motion("feed-items", ".feed-item", { from: { opacity: 0, y: 16 }, duration: 0.35, stagger: 0.05, }).onPageLoad(); }); let debounceTimer: ReturnType; const observer = new MutationObserver(() => { clearTimeout(debounceTimer); debounceTimer = setTimeout(() => ctx.refresh(), 150); }); observer.observe(document.querySelector("#feed")!, { childList: true, subtree: true, }); ``` See [SPA Integration](/docs/sdk/spa-integration) for framework-specific lifecycle patterns. --- ## Page-Specific Targeting (URL Matching) Run an animation only on pages whose URL matches a pattern. Check `window.location` before registering the timeline. ### Exact Path Match ```typescript import { Motion } from "@motion.page/sdk"; if (window.location.pathname === "/about") { Motion("about-hero", ".hero-title", { from: { opacity: 0, y: 40 }, duration: 0.8, ease: "power3.out", }).onPageLoad(); } ``` ### RegEx URL Patterns Use `RegExp.test()` to match URL patterns — slug fragments, query strings, or path segments: ```typescript import { Motion } from "@motion.page/sdk"; // Any URL containing "blog" if (/blog/.test(window.location.pathname)) { Motion("blog-reveal", ".post-card", { from: { opacity: 0, y: 24 }, duration: 0.5, stagger: 0.08, }).onScroll({ each: true }); } // Case-insensitive match — product pages if (/\/products?\//i.test(window.location.pathname)) { Motion("product-fade", ".product-image", { from: { scale: 0.97, opacity: 0 }, duration: 0.6, ease: "power2.out", }).onScroll({ each: true }); } // Homepage only (root path) if (/^\/$/.test(window.location.pathname)) { Motion("home-hero", "#hero", { from: { opacity: 0, y: 60 }, duration: 1, ease: "power3.out", }).onPageLoad(); } ``` ### Multiple Page Conditions ```typescript import { Motion } from "@motion.page/sdk"; const path = window.location.pathname; const isLanding = /^\/(home|landing|index)/.test(path); const isProduct = /\/product\//.test(path); if (isLanding || isProduct) { Motion("conversion-cta", ".cta-block", { from: { opacity: 0, scale: 0.96 }, duration: 0.7, ease: "back.out", }).onScroll({ toggleActions: "play none none none" }); } ``` --- ## WordPress: Post Type Targeting In the **Motion.page Builder**, the **Advanced Targeting** panel lets you restrict a timeline to specific WordPress post types, template pages, or URL patterns — without writing any code. ### How It Works Open the **Advanced Targeting** panel in the Left Panel under the timeline name. Use the dropdown to select: | Option | What it matches | |--------|-----------------| | Post type slug (e.g. `post`, `page`, `product`) | All posts of that type | | `$search` | The WordPress search results page | | `$404` | The 404 error page | | Custom RegEx string | Any URL matching the pattern | Selected post types are stored per timeline and evaluated at runtime — the animation only loads on matching pages. ### RegEx in the Builder The Advanced Targeting input also accepts custom **RegEx strings**. Enter the pattern without delimiters — the Builder wraps it automatically: | Input | Matches | |-------|---------| | `about` | Any URL containing "about" | | `\/shop\/.*\/review` | URLs like `/shop/product-name/review` | | `\?ref=email` | URLs with the `?ref=email` query parameter | **Rules:** - Delimiters (`/…/`) are added automatically — do not include them. - The only supported flag is `i` (case-insensitive). Add it manually if needed. - Escape special regex characters with `\` — e.g. use `\.` to match a literal dot. > **Example:** To match all pages with the slug `services` or `service`, enter `services?` — the `?` makes the trailing `s` optional. ### WordPress Code Equivalent If you're using the SDK directly in a WordPress theme, replicate the same logic with `window.location`: ```typescript import { Motion } from "@motion.page/sdk"; // Equivalent to targeting the "product" post type // WordPress adds the slug in the URL — match accordingly if (/\/product\//.test(window.location.pathname)) { Motion("product-reveal", ".entry-content", { from: { opacity: 0, y: 20 }, duration: 0.6, ease: "power2.out", }).onPageLoad(); } ``` For the full WordPress plugin targeting workflow, see the Builder documentation. --- ## Tips and Gotchas **Selectors resolve at registration time.** If elements don't exist yet when `Motion()` is called, the timeline targets zero elements. Use `Motion.context()` with `ctx.refresh()` for late-rendered content. **`each: true` is per-element, not per-page.** The `each` option on triggers creates independent timeline instances for each matched element. It is separate from page-level URL targeting. **Compound selectors with stagger.** When using stagger across a compound selector, all matched elements animate in DOM order regardless of which sub-selector found them: ```typescript // All .card and .featured-card elements, staggered together in DOM order Motion("mixed", [".card", ".featured-card"], { from: { opacity: 0, y: 20 }, duration: 0.5, stagger: 0.06, }).onPageLoad(); ``` **Scoping prevents bleed.** Always use `Motion.utils.toArray(selector, scope)` when the same component renders multiple times on one page. Without a scope, `.card` matches every `.card` on the page. --- ## Related - [Motion() Factory](/docs/sdk/motion-factory) — `TargetInput` types and `Motion.utils.toArray()` - [Scroll Trigger](/docs/sdk/scroll-trigger) — `each: true` for per-element scroll instances - [SPA Integration](/docs/sdk/spa-integration) — `Motion.context()` for framework lifecycle management - [Responsive Animations](/docs/sdk/responsive) — `matchMedia` for screen-size targeting --- ## Transform Origin > Set the origin point for scale, rotation, and transform animations. URL: https://motion.page/docs/sdk/transform-origin import LivePreview from "../../components/docs/blocks/LivePreview"; **Transform origin** is the pivot point around which `scale`, `rotate`, `skew`, and other transform animations occur. By default every element transforms from its center (`50% 50%`), but you can move that anchor to any corner, edge, or custom position. ## Basic Usage Pass `transformOrigin` as a string inside `from` or `to`. It can be set on either endpoint or on both — the origin applies for the duration of the animation. ```typescript import { Motion } from "@motion.page/sdk"; // Rotate around the top-center (pendulum effect) Motion("pendulum", ".arm", { from: { rotate: -30, transformOrigin: "50% 0%" }, to: { rotate: 30 }, duration: 1, ease: "power1.inOut", repeat: { times: -1, yoyo: true }, }).onPageLoad(); ``` ## Accepted Values `transformOrigin` accepts the same syntax as the CSS `transform-origin` property. | Format | Example | Description | |--------|---------|-------------| | Keywords | `"center"` | Center of the element (same as `"50% 50%"`) | | Two keywords | `"top left"` | Top-left corner (`"0% 0%"`) | | Percentages | `"50% 100%"` | Bottom-center | | Pixels | `"0px 0px"` | Exact pixel coordinates | | Mixed | `"20px 80%"` | Pixel X, percentage Y | ### Keyword Reference | Keyword | Equivalent | |---------|-----------| | `"top left"` | `"0% 0%"` | | `"top center"` | `"50% 0%"` | | `"top right"` | `"100% 0%"` | | `"center left"` | `"0% 50%"` | | `"center"` | `"50% 50%"` | | `"center right"` | `"100% 50%"` | | `"bottom left"` | `"0% 100%"` | | `"bottom center"` | `"50% 100%"` | | `"bottom right"` | `"100% 100%"` | ## Effect on Different Transforms ### Rotation The element pivots around the origin point. Moving the origin to a corner or edge produces a hinge or pendulum effect. ```typescript // Hinge from top-left corner Motion("hinge", ".door", { from: { rotate: 0, transformOrigin: "0% 50%" }, to: { rotate: 90 }, duration: 0.6, ease: "power2.inOut", }).onClick({ each: true }); ``` ### Scale The element grows or shrinks toward the origin point. A corner origin makes the element appear to emerge from that corner rather than its center. ```typescript // Grow from the bottom-left corner Motion("corner-pop", ".card", { from: { scale: 0, transformOrigin: "0% 100%" }, duration: 0.5, ease: "back.out(1.7)", }).onPageLoad(); ``` ### Skew The shear distortion anchors at the origin, which controls which point of the element stays fixed as the rest of it tilts. ```typescript // Skew with bottom anchored Motion("tilt", ".panel", { from: { skewX: 15, opacity: 0, transformOrigin: "50% 100%" }, duration: 0.55, ease: "power3.out", }).onPageLoad(); ```
Center (default)
Bottom left
`} css={`.scene { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; padding: 20px 32px; height: 100%; box-sizing: border-box; justify-content: center; } .label { font: 11px/1 monospace; color: #999; margin-bottom: 2px; } .box { width: 60px; height: 60px; background: #6633EE; border-radius: 8px; margin-bottom: 12px; } .box:last-child { margin-bottom: 0; }`} js={`import { Motion } from "https://cloud.motion.page/sdk/latest.js"; Motion("center-scale", ".box.center", { from: { scale: 0, transformOrigin: "50% 50%" }, duration: 0.6, ease: "back.out", repeat: { times: -1, yoyo: true, delay: 0.6 }, }).play(); Motion("corner-scale", ".box.corner", { from: { scale: 0, transformOrigin: "0% 100%" }, duration: 0.6, ease: "back.out", repeat: { times: -1, yoyo: true, delay: 0.6 }, }).play();`} /> ## Common Patterns ### Corner Pop Scale an element in from one of its corners — a classic way to make cards, tooltips, or dropdowns feel anchored to a fixed point. ```typescript // Dropdown opening from its top-left corner Motion("dropdown", ".dropdown-menu", { from: { scale: 0.8, opacity: 0, transformOrigin: "0% 0%" }, duration: 0.25, ease: "power2.out", }).onClick({ each: true }); ``` ### Pendulum Swing Set the origin to the top-center so the element rotates like a pendulum hanging from that point. ```typescript Motion("swing", ".pendulum", { from: { rotate: -25, transformOrigin: "50% 0%" }, to: { rotate: 25 }, duration: 1.4, ease: "power1.inOut", repeat: { times: -1, yoyo: true }, }).onPageLoad(); ``` ### Grow from Bottom Reveal bar charts, progress indicators, or loaders that rise from the bottom edge. ```typescript Motion("bar-reveal", ".bar", { from: { scaleY: 0, transformOrigin: "50% 100%" }, duration: 0.7, ease: "power3.out", stagger: 0.08, }).onScroll({ scrub: false, toggleActions: "play none none none" }); ``` ### Click-to-Fold Toggle Use an edge-anchored origin with `onClick` to make an element fold open and shut like a flap. ```typescript Motion("flap", ".flap", { to: { rotateX: 90, transformOrigin: "50% 0%" }, duration: 0.35, ease: "power2.inOut", }).onClick({ each: true, onLeave: "reverse" }); ``` ## Notes - `transformOrigin` is not animated between `from` and `to` — it sets a fixed anchor for the entire animation. Place it in either endpoint; the value is applied before the tween begins. - The default is `"50% 50%"` (element center). You only need to set it when you want a non-center pivot. - Pixel values are relative to the element's top-left corner, not the page. - For transforms applied across a multi-step timeline, set `transformOrigin` in the first step that needs the custom anchor. See [Scale](/docs/sdk/scale), [Skew](/docs/sdk/skew), and [Rotation](/docs/sdk/rotation) for the transform properties that `transformOrigin` affects. --- ## Stagger > Animate multiple targets in sequence with configurable delay, direction, and grid patterns. URL: https://motion.page/docs/sdk/stagger import LivePreview from "../../components/docs/blocks/LivePreview"; `stagger` adds a per-element delay offset when animating multiple targets. Instead of all elements moving at once, each one starts slightly after the previous — creating a cascade effect. ## Basic Stagger Pass a number directly to `stagger`. Each element waits that many seconds more than the one before it. ```typescript import { Motion } from "@motion.page/sdk"; Motion("list", ".item", { from: { opacity: 0, y: 20 }, duration: 0.5, stagger: 0.08, ease: "power2.out", }).onPageLoad(); ``` Element 1 starts at `0s`, element 2 at `0.08s`, element 3 at `0.16s`, and so on.
Item one
Item two
Item three
Item four
Item five
`} css={`.list { display: flex; flex-direction: column; gap: 10px; padding: 20px; } .item { background: #6633EE; color: white; font-family: sans-serif; font-size: 14px; font-weight: 600; padding: 12px 20px; border-radius: 8px; }`} js={`import { Motion } from "https://cloud.motion.page/sdk/latest.js"; Motion("list-in", ".item", { from: { opacity: 0, y: 20 }, duration: 0.5, stagger: 0.08, ease: "power2.out", repeat: { times: -1, yoyo: true, delay: 0.6 }, }).play();`} /> ## StaggerVars Object For full control, pass an object instead of a number. The shorthand `stagger: 0.08` is equivalent to `stagger: { each: 0.08, from: 'start' }`. | Property | Type | Default | Description | |----------|------|---------|-------------| | `each` | `number` | — | Seconds between each element | | `amount` | `number` | — | Total spread across all elements (alternative to `each`) | | `from` | `string \| number` | `'start'` | Direction or origin of the stagger | | `grid` | `'auto' \| [number, number]` | — | Enable 2D grid staggering | | `axis` | `'x' \| 'y'` | — | Axis to measure distance for grid stagger | | `ease` | `string` | `'none'` | Easing for stagger delay distribution | ### `each` vs `amount` Use `each` when you care about the gap between adjacent elements. Use `amount` when you care about the total time the full stagger takes regardless of element count. ```typescript // Always 0.1s between each element — total time grows with more elements stagger: { each: 0.1 } // Always 1s total spread — 10 elements = 0.1s each, 20 elements = 0.05s each stagger: { amount: 1 } ``` ## `from` — Stagger Origin `from` controls which element starts first and how the cascade radiates outward. | Value | Description | |-------|-------------| | `'start'` | First element leads (default) | | `'end'` | Last element leads — sequence runs in reverse | | `'center'` | Middle element leads, radiates outward | | `'edges'` | Both ends lead simultaneously, meet in the middle | | `'random'` | Random order on every play | | `number` | A specific element index leads (0-based) — e.g. `from: 2` starts from the third element | ```typescript // Default — first to last Motion("list-start", ".item", { from: { opacity: 0, y: 16 }, duration: 0.5, stagger: { each: 0.07, from: "start" }, ease: "power2.out", }).onPageLoad(); // Reverse — last to first Motion("list-end", ".item", { from: { opacity: 0, y: 16 }, duration: 0.5, stagger: { each: 0.07, from: "end" }, ease: "power2.out", }).onPageLoad(); // Center outward — good for symmetrical layouts Motion("list-center", ".item", { from: { opacity: 0, scale: 0.8 }, duration: 0.4, stagger: { each: 0.06, from: "center" }, ease: "back.out", }).onPageLoad(); // Edges inward — both sides converge to the middle Motion("list-edges", ".item", { from: { opacity: 0 }, duration: 0.4, stagger: { each: 0.06, from: "edges" }, }).onPageLoad(); // Random — each play has a different order Motion("list-random", ".item", { from: { opacity: 0, scale: 0.9 }, duration: 0.4, stagger: { each: 0.05, from: "random" }, }).onPageLoad(); // Specific index — ripple from the third card Motion("list-from-index", ".card", { from: { opacity: 0, scale: 0.85 }, duration: 0.5, stagger: { each: 0.06, from: 2 }, ease: "power2.out", }).onClick({ each: true }); ``` ## `ease` — Stagger Distribution `ease` controls how the delay offsets are **distributed** across elements — not the easing of the animation itself. Without it, delays are evenly spaced. With it, elements bunch up at the start, end, or middle of the sequence. ```typescript // Delays accelerate — early elements are close together, later ones spread out Motion("ease-in-stagger", ".dot", { from: { opacity: 0, y: 20 }, duration: 0.4, stagger: { each: 0.08, ease: "power2.in" }, }).onPageLoad(); // Delays decelerate — quick burst at the start, trailing off Motion("ease-out-stagger", ".dot", { from: { opacity: 0, y: 20 }, duration: 0.4, stagger: { each: 0.08, ease: "power2.out" }, }).onPageLoad(); ``` ## `grid` — 2D Stagger Patterns Grid stagger measures the 2D distance from the origin element, creating ripple or wave effects across rows and columns. Set `grid: 'auto'` and the SDK infers the grid dimensions from the layout of elements in the DOM. For irregular or custom grids, pass explicit `[rows, columns]`. ```typescript // Ripple from center of a CSS grid Motion("grid-ripple", ".cell", { from: { opacity: 0, scale: 0.6 }, duration: 0.4, stagger: { each: 0.04, from: "center", grid: "auto", }, ease: "power2.out", }).onPageLoad(); // Explicit 3×4 grid, wave from top-left Motion("grid-wave", ".tile", { from: { opacity: 0, y: 20 }, duration: 0.5, stagger: { each: 0.05, from: "start", grid: [3, 4], }, ease: "power2.out", }).onPageLoad(); ``` ### `axis` — Directional Grid Waves Combine `grid` with `axis` to constrain stagger distance to a single axis — producing row-by-row or column-by-column waves instead of true radial ripples. ```typescript // Animate row by row (top to bottom) Motion("row-wave", ".cell", { from: { opacity: 0, y: 12 }, duration: 0.4, stagger: { each: 0.05, grid: "auto", axis: "y", }, ease: "power2.out", }).onPageLoad(); // Animate column by column (left to right) Motion("col-wave", ".cell", { from: { opacity: 0, x: 12 }, duration: 0.4, stagger: { each: 0.05, grid: "auto", axis: "x", }, ease: "power2.out", }).onPageLoad(); ``` ## Common Patterns ### Staggered Scroll Reveal ```typescript Motion("cards", ".card", { from: { opacity: 0, y: 40 }, duration: 0.6, stagger: 0.1, ease: "power2.out", }).onScroll({ scrub: false, toggleActions: "play none none none" }); ``` ### Text Reveal with Stagger Split text into words or characters and stagger them for a dramatic entrance. ```typescript Motion("headline", "h1", { split: "words", mask: true, from: { y: "110%" }, duration: 0.6, stagger: { each: 0.06, from: "start" }, ease: "power3.out", }).onPageLoad(); ``` See [Text Splitter](/docs/sdk/split-text) for the full text-splitting API. ### Grid Ripple on Hover ```typescript Motion("grid-hover", ".cell", { to: { scale: 1.1, backgroundColor: "#6633EE" }, duration: 0.3, stagger: { each: 0.03, from: "center", grid: "auto", }, ease: "power2.out", }).onHover({ onLeave: "reverse" }); ``` ### Scene Delay + Stagger Add a base `delay` to pause before the staggered sequence begins. The stagger offsets stack on top. ```typescript // Element 1 starts at 0.3s // Element 2 starts at 0.38s // Element 3 starts at 0.46s Motion("delayed-list", ".item", { from: { opacity: 0, x: -20 }, duration: 0.5, delay: 0.3, stagger: 0.08, ease: "power2.out", }).onPageLoad(); ``` See [Duration & Delay](/docs/sdk/duration-delay) for how `delay` and `stagger` interact. ## API Reference ```typescript // Shorthand — seconds between each element stagger?: number // Full object interface StaggerVars { each?: number; // Seconds between each element amount?: number; // Total spread across all elements from?: 'start' | 'end' | 'center' | 'edges' | 'random' | number; grid?: 'auto' | [number, number]; // 2D grid stagger axis?: 'x' | 'y'; // Grid stagger axis ease?: string; // Easing for delay distribution } ``` --- Related: [Duration & Delay](/docs/sdk/duration-delay) · [Text Splitter](/docs/sdk/split-text) · [Easing](/docs/sdk/easing) --- ## SPA Integration > Use the SDK with React, Vue, Next.js, and Astro — cleanup, page transitions, lifecycle. URL: https://motion.page/docs/sdk/spa-integration import CodeBlock from "../../components/docs/blocks/CodeBlock.astro"; The SDK is client-side JavaScript — it must run in the browser and always requires cleanup when components unmount or routes change. Each framework has its own lifecycle hooks for this. --- ## React Use `useEffect` to create animations after mount and return a cleanup function that kills them on unmount. Target elements with `useRef` to avoid stale selectors. ```tsx import { useEffect, useRef } from "react"; import { Motion } from "@motion.page/sdk"; export function FadeCard() { const cardRef = useRef(null); useEffect(() => { if (!cardRef.current) return; Motion("fade-card", cardRef.current, { from: { opacity: 0, y: 24 }, duration: 0.5, ease: "power2.out", }).onPageLoad(); return () => { Motion.kill("fade-card"); }; }, []); return
Hello
; } ```
Use `Motion.kill(name)` to remove a specific timeline, or `Motion.killAll()` to clear every timeline registered on the page. ### Shared cleanup hook Extract cleanup into a reusable hook when multiple components need the same pattern: ```ts import { useEffect } from "react"; import { Motion } from "@motion.page/sdk"; export function useMotion(setup: () => void, deps: React.DependencyList = []) { useEffect(() => { setup(); return () => Motion.killAll(); // eslint-disable-next-line react-hooks/exhaustive-deps }, deps); } ``` ### Scroll triggers after dynamic content If your component renders a list from async data, call `Motion.refreshScrollTriggers()` after the data loads so scroll positions are recalculated: ```tsx useEffect(() => { if (!items.length) return; Motion("list-reveal", ".list-item", { from: { opacity: 0, y: 20 }, duration: 0.4, stagger: 0.08, }).onScroll({ each: true }); Motion.refreshScrollTriggers(); return () => Motion.kill("list-reveal"); }, [items]); ``` --- ## Vue Use `onMounted` and `onUnmounted` to manage the animation lifecycle. Access elements with `ref` template refs. ```vue ``` ### Vue Router page transitions Kill all timelines on route leave so animations don't leak between pages: ```ts // router/index.ts import { Motion } from "@motion.page/sdk"; router.afterEach(() => { Motion.killAll(); }); ``` --- ## Next.js The SDK is **client-side only** — it cannot run in Server Components. Any file that imports `Motion` must be a Client Component. ### App Router Add `"use client"` at the top of any component that uses the SDK: ```tsx "use client"; import { useEffect } from "react"; import { Motion } from "@motion.page/sdk"; export function HeroSection() { useEffect(() => { Motion("hero-fade", "#hero-heading", { from: { opacity: 0, y: 32 }, duration: 0.7, ease: "power3.out", }).onPageLoad(); return () => Motion.kill("hero-fade"); }, []); return

Welcome

; } ```
### Page transitions with App Router Next.js App Router navigations unmount the previous page tree. The `useEffect` cleanup above handles this automatically — no extra route-change listener needed. For shared layout components that persist across navigations (e.g. a nav bar), use `pathname` as a dependency to reinitialise: ```tsx "use client"; import { useEffect } from "react"; import { usePathname } from "next/navigation"; import { Motion } from "@motion.page/sdk"; export function PageTransition({ children }: { children: React.ReactNode }) { const pathname = usePathname(); useEffect(() => { Motion("page-in", "main", { from: { opacity: 0 }, duration: 0.35, }).onPageLoad(); return () => Motion.kill("page-in"); }, [pathname]); return <>{children}; } ``` --- ## Astro Add `client:load` to any Astro component that uses the SDK so it hydrates in the browser. Pure `.astro` files can run the SDK in an inline ` ``` ### View Transitions API Astro's View Transitions dispatch `astro:page-load` and `astro:before-swap` events. Kill animations before the swap and re-initialise on each new page load: ```astro ``` --- ## Cleanup reference | Method | When to use | |--------|-------------| | `Motion.kill("name")` | Remove one specific timeline by name | | `Motion.killAll()` | Remove all timelines — use on route change or full page teardown | | `Motion.refreshScrollTriggers()` | Recalculate scroll positions after dynamic content renders | | `Motion.context(() => { ... })` | Scope multiple timelines so they can all be torn down together | ### Motion.context `Motion.context` is useful when a component creates several timelines and you want a single cleanup call: ```ts import { Motion } from "@motion.page/sdk"; const ctx = Motion.context(() => { Motion("nav-fade", ".nav-item", { from: { opacity: 0, x: -12 }, stagger: 0.06, duration: 0.4, }).onPageLoad(); Motion("nav-underline", ".nav-link", { to: { scaleX: 1 }, duration: 0.25, }).onHover({ onLeave: "reverse" }); }); // On cleanup — kills both timelines ctx.revert(); ``` --- ## Related - [Timeline Control](/docs/sdk/timeline-control) — `Motion.get()`, `Motion.has()`, manual play/pause - [Page Load](/docs/sdk/page-load) — `.onPageLoad()` trigger options - [Scroll Trigger](/docs/sdk/scroll-trigger) — `.onScroll()` and `refreshScrollTriggers()` --- ## ScrollTrigger Advanced > Pin elements, add spacing, configure snap points, and use scroll markers for debugging. URL: https://motion.page/docs/sdk/scroll-trigger-advanced import LivePreview from "../../components/docs/blocks/LivePreview"; Build on the basics of `.onScroll()` with pinning, snap points, custom scroll containers, debug markers, and progress callbacks. These options let you create complex scroll-driven experiences — from sticky sections and horizontal galleries to scroll-linked progress bars. If you're new to scroll triggers, start with [Scroll Trigger](/docs/sdk/scroll-trigger) first. --- ## Pin **Pinning** fixes an element in place while the user continues to scroll, keeping it visible throughout the scroll range you define. Set `pin: true` to pin the animation's target element. ```typescript import { Motion } from "@motion.page/sdk"; Motion("sticky-panel", ".panel", { from: { opacity: 0, y: 30 }, duration: 1, }).onScroll({ scrub: true, pin: true, start: "top top", end: "+=600", }); ```
ADVANCED SCROLLPin + scrub + onUpdate
0%
Scroll inside this panel ↓
SCROLL PROGRESS

One pinned card.
A continuous story.

DiscoverBuildLaunch
End of the trigger range
`} css={`.scroll-demo { width: min(720px, 100%); padding: 18px; border: 1px solid rgba(143, 158, 255, 0.22); border-radius: 20px; background: #080b20; } .demo-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin-bottom: 14px; } .demo-heading div { display: grid; gap: 4px; } .demo-heading span { color: #7f89ad; font-size: 10px; font-weight: 800; letter-spacing: 0.14em; } .demo-heading strong { color: #f3f4ff; font-size: 16px; } .progress-label { color: #9e83ff; font-size: 24px; } .demo-scroller { position: relative; height: 300px; overflow-y: auto; overscroll-behavior: contain; border: 1px solid rgba(143, 158, 255, 0.16); border-radius: 15px; background: linear-gradient(180deg, rgba(29, 35, 69, 0.88), rgba(7, 10, 27, 0.98)); scrollbar-color: #7455e8 transparent; } .scroll-intro, .scroll-outro { height: 190px; display: grid; place-items: center; color: #7780a4; font-size: 12px; letter-spacing: 0.08em; text-transform: uppercase; } .trigger-zone { min-height: 660px; padding: 28px 18px; background: radial-gradient(circle at 50% 20%, rgba(104, 74, 226, 0.2), transparent 48%); } .pin-card { width: min(460px, 92%); min-height: 220px; margin: 0 auto; padding: 24px; border: 1px solid rgba(164, 178, 255, 0.24); border-radius: 18px; background: linear-gradient(145deg, rgba(32, 38, 77, 0.97), rgba(12, 16, 41, 0.98)); box-shadow: 0 24px 70px rgba(0, 0, 0, 0.38); } .card-step { color: #9476ff; font-size: 10px; font-weight: 800; letter-spacing: 0.14em; } .pin-card h3 { margin: 12px 0 25px; color: #f5f6ff; font-size: clamp(23px, 4vw, 34px); line-height: 1.05; } .progress-track { height: 5px; border-radius: 999px; background: rgba(150, 160, 205, 0.17); overflow: hidden; } .progress-fill { width: 100%; height: 100%; border-radius: inherit; background: linear-gradient(90deg, #7448ea, #50d5ff); transform-origin: 0 50%; } .step-row { display: flex; justify-content: space-between; margin-top: 12px; color: #6f789a; font-size: 11px; } .step-row .is-active { color: #d9d3ff; } @media (max-width: 520px) { .scroll-demo { padding: 12px; } .demo-scroller { height: 310px; } .pin-card { width: 96%; padding: 20px; } }`} js={`import { Motion } from "https://cloud.motion.page/sdk/latest.js"; const label = document.querySelector(".progress-label"); const steps = [...document.querySelectorAll(".step-row span")]; Motion("advanced-scroll-preview", ".progress-fill", { from: { scaleX: 0 }, duration: 1, ease: "none", onUpdate: (progress) => { label.textContent = Math.round(progress * 100) + "%"; const active = Math.min(2, Math.floor(progress * 3)); steps.forEach((step, index) => step.classList.toggle("is-active", index === active)); }, }).onScroll({ target: ".trigger-zone", scroller: ".demo-scroller", start: "top top", end: "bottom bottom", scrub: true, pin: ".pin-card", pinSpacing: false, });`} /> The element stays fixed at its `start` position until the scroll distance defined by `end` has been traveled. After that, it unpins and scrolls normally. ### Pin a Different Element Pass a CSS selector string to `pin` to pin a different element than the one being animated — typically a parent wrapper. ```typescript // Pin the section while the inner content animates Motion("reveal-inner", ".content-inner", { from: { opacity: 0, y: 60 }, duration: 1, ease: "power2.out", }).onScroll({ scrub: true, pin: ".section-wrapper", start: "top top", end: "+=800", }); ``` --- ## pinSpacing When an element is pinned, the page loses that section's natural scroll height. **`pinSpacing`** compensates by adding space below (or around) the pinned element so subsequent content doesn't jump up unexpectedly. | Value | Behavior | |-------|----------| | `true` (default) | Adds `padding-bottom` to push content down | | `"padding"` | Same as `true` — explicit padding mode | | `"margin"` | Adds `margin-bottom` instead of padding | | `false` | Disables spacing entirely — content flows directly under the pinned element | ```typescript Motion("pinned-hero", ".hero", { to: { scale: 0.9, opacity: 0.6 }, duration: 1, }).onScroll({ scrub: true, pin: true, pinSpacing: "margin", start: "top top", end: "+=500", }); ``` Use `pinSpacing: false` when the pinned element overlaps content intentionally (e.g., sticky overlays, fullscreen covers). --- ## Snap **Snap** quantizes the animation's scroll-derived progress to discrete points. It does not move or lock the browser's scrollbar. This is useful for step-by-step galleries, onboarding flows, and presentation-style layouts. ### Snap to Even Intervals Pass a single `number` between 0 and 1. The SDK divides the range into equal steps at that interval. ```typescript // Snaps to 0, 0.25, 0.5, 0.75, 1.0 Motion("stepped", ".panel", { from: { opacity: 0.2 }, duration: 1, }).onScroll({ scrub: true, snap: 0.25, }); ``` ### Snap to Specific Points Pass an array of progress values (0–1) to define exact snap positions. ```typescript // Snap to 3 panels in unequal proportions Motion("custom-snap", ".panel", { from: { x: "0%" }, to: { x: "-200%" }, duration: 1, }).onScroll({ scrub: true, pin: true, start: "top top", end: "+=2000", snap: [0, 0.4, 1], }); ``` ### Snap with a Custom Function For dynamic layouts or non-linear snapping, pass a function. It receives the raw scroll progress (0–1) and returns the snapped value. ```typescript Motion("dynamic-snap", ".slider", { to: { x: "-300%" }, duration: 1, }).onScroll({ scrub: true, pin: true, end: "+=3000", snap: (progress) => Math.round(progress * 3) / 3, }); ``` ### Snapping for Horizontal Scroll Combine snap with `each` or compute the increment from your section count: ```typescript const panels = Motion.utils.toArray(".h-panel"); const snapIncrement = 1 / (panels.length - 1); Motion("h-gallery", ".h-track", { to: { x: `-${(panels.length - 1) * 100}%` }, duration: 1, }).onScroll({ scrub: true, pin: true, start: "top top", end: `+=${panels.length * 100}vh`, snap: snapIncrement, }); ``` --- ## Markers Enable `markers: true` to render visual debug overlays showing exactly where your `start` and `end` positions fall relative to both the scroller and the animated element. ```typescript Motion("debug-reveal", ".section", { from: { opacity: 0, y: 50 }, duration: 0.8, }).onScroll({ scrub: true, start: "top 80%", end: "top 30%", markers: true, }); ``` Two pairs of markers appear: - **Green lines** — `start` positions (scroller and element) - **Red lines** — `end` positions (scroller and element) ### Marker Configuration Pass a `MarkerConfig` object to customize marker colors, size, and horizontal offset. | Property | Type | Default | Description | |----------|------|---------|-------------| | `startColor` | `string` | `"lime"` | CSS color for start markers | | `endColor` | `string` | `"red"` | CSS color for end markers | | `fontSize` | `string` | — | Font size for marker labels, e.g. `"12px"` | | `fontWeight` | `string` | — | Font weight for marker labels | | `indent` | `number` | `0` | Horizontal offset in px — use when multiple trigger zones overlap | ```typescript Motion("debug-custom", ".hero", { from: { opacity: 0 }, duration: 1, }).onScroll({ scrub: true, markers: { startColor: "blue", endColor: "orange", fontSize: "11px", indent: 40, }, }); ``` > **Remove markers in production.** Delete the `markers` option for a live page. Use `Motion.cleanup()` during teardown only: it safely removes marker/spacer nodes and detaches active ScrollTriggers while leaving their timelines registered. --- ## Horizontal Scrolling Horizontal scroll scenes require a **pinned container** that holds while the inner track translates on the X axis. The total scroll travel defines how far the track moves. ```typescript import { Motion } from "@motion.page/sdk"; const panels = Motion.utils.toArray(".panel"); Motion("horizontal-scroll", ".track", { to: { x: `-${(panels.length - 1) * 100}%` }, duration: 1, }).onScroll({ scrub: true, pin: ".pin-container", start: "top top", end: `+=${panels.length * 100}vh`, snap: 1 / (panels.length - 1), }); ``` **Required CSS:** ```css .pin-container { overflow: hidden; height: 100vh; } .track { display: flex; width: calc(100% * var(--panel-count)); /* or set explicitly */ } .panel { width: 100vw; height: 100vh; flex-shrink: 0; } ``` The `.track` element moves from `x: 0` to `x: -N * 100%` as the user scrolls through the pinned range, while the container stays fixed at the top of the viewport. --- ## Custom Scroll Container (`scroller`) By default, scroll triggers listen to the **window** scroll. Use `scroller` to attach a trigger to any scrollable element instead. ```typescript Motion("inner-scroll", ".list-item", { from: { opacity: 0, x: -20 }, duration: 0.5, stagger: { each: 0.08 }, }).onScroll({ scrub: false, toggleActions: "play none none none", start: "top 90%", scroller: ".sidebar-panel", }); ``` `scroller` accepts either a CSS selector string or a direct `Element` reference: ```typescript const container = document.querySelector(".modal-body"); Motion("modal-anim", ".modal-item", { from: { opacity: 0, y: 20 }, duration: 0.4, stagger: 0.05, }).onScroll({ toggleActions: "play none none none", start: "top 85%", scroller: container, }); ``` > The scroller element must have `overflow: scroll` or `overflow: auto` and a fixed height to be scrollable. The window scroll is used as fallback when `scroller` is `undefined`. --- ## Scroll Lifecycle and `toggleActions` For non-scrub animations, **`toggleActions`** controls exactly what the timeline does at each of the four scroll lifecycle events. It's a space-separated string of four actions: ``` toggleActions: "onEnter onLeave onEnterBack onLeaveBack" ``` | Position | Event | When it fires | |----------|-------|---------------| | 1st | `onEnter` | Scrolling **down** into the trigger zone | | 2nd | `onLeave` | Scrolling **down** past the end of the trigger zone | | 3rd | `onEnterBack` | Scrolling **up** back into the trigger zone | | 4th | `onLeaveBack` | Scrolling **up** out the top of the trigger zone | **Valid actions:** `play` · `pause` · `resume` · `reverse` · `restart` · `reset` · `complete` · `none` **Default:** `"play none none none"` ### Common Patterns ```typescript // Play once and stay — never reverse Motion("once-reveal", ".card", { from: { opacity: 0, y: 40 }, duration: 0.7, ease: "power2.out", }).onScroll({ each: true, toggleActions: "play none none none", start: "top 85%", }); // Play forward on enter, reverse on leave back (no action when past end) Motion("in-out", ".feature", { from: { opacity: 0, scale: 0.9 }, duration: 0.6, }).onScroll({ each: true, toggleActions: "play none none reverse", start: "top 80%", end: "bottom 20%", }); // Restart every time the element enters Motion("repeating-anim", ".highlight", { from: { backgroundColor: "transparent" }, to: { backgroundColor: "#fbbf24" }, duration: 0.5, }).onScroll({ each: true, toggleActions: "restart none none reset", start: "top 75%", }); ``` --- ## Progress Tracking with `onUpdate` Use the `onUpdate` callback inside `AnimationConfig` to run code every frame as the animation progresses. With scrubbing enabled, this callback fires continuously as the user scrolls — making it ideal for syncing secondary UI elements like reading progress bars, counters, or parallax overlays. ```typescript import { Motion } from "@motion.page/sdk"; Motion("reading-progress", ".progress-bar", { from: { width: "0%" }, to: { width: "100%" }, duration: 1, onUpdate: (progress) => { const label = document.querySelector(".progress-label"); if (label) { label.textContent = `${Math.round(progress * 100)}%`; } }, }).onScroll({ scrub: true, start: "top top", end: "bottom bottom", }); ``` `onUpdate` receives a normalized progress value between `0` and `1`. ### Driving Multiple Elements Because `onUpdate` runs in the animation's update loop, you can drive any number of secondary effects without creating additional timelines. ```typescript const nav = document.querySelector(".navbar"); const progress = document.querySelector(".nav-progress"); Motion("page-scroll", "body", { to: { opacity: 1 }, // dummy — only onUpdate drives behavior duration: 1, onUpdate: (p) => { // Darken navbar as user scrolls down const alpha = Math.min(p * 2, 1); nav?.style.setProperty("--nav-bg-alpha", String(alpha)); // Widen progress bar progress?.style.setProperty("width", `${p * 100}%`); }, }).onScroll({ scrub: true, start: "top top", end: "bottom bottom", }); ``` --- ## Complete API Reference ### ScrollConfig ```typescript interface ScrollConfig { target?: string | Element; endTarget?: string | Element; start?: string; end?: string; scrub?: boolean | number; pin?: boolean | string; pinSpacing?: boolean | "margin" | "padding"; snap?: number | number[] | ((progress: number) => number); markers?: boolean | MarkerConfig; scroller?: string | Element; toggleActions?: string; each?: boolean; } ``` ### MarkerConfig ```typescript interface MarkerConfig { startColor?: string; // CSS color. Default: "lime" endColor?: string; // CSS color. Default: "red" fontSize?: string; // e.g. "12px" fontWeight?: string; indent?: number; // px horizontal offset } ``` ### `onUpdate` in AnimationConfig ```typescript interface AnimationConfig { // ...other properties onUpdate?: (progress: number) => void; // progress: 0–1 } ``` --- ## Tips and Gotchas **Recalculate after layout changes.** If your page layout shifts after initialization (e.g., fonts load, images resize, components mount), scroll trigger positions become stale. Call `Motion.refreshScrollTriggers()` to recompute all positions. ```typescript window.addEventListener("load", () => { Motion.refreshScrollTriggers(); }); ``` **Scrub disables `toggleActions`.** When `scrub` is enabled, the animation progress is tied directly to scroll position. Setting `toggleActions` alongside `scrub` has no effect — use scrub OR toggleActions, not both. **`end` with `+=` is relative to `start`.** `end: "+=800"` means 800px of scroll travel measured from the `start` point, not from the element's position. ```typescript // 800px of scroll travel from wherever start fires end: "+=800" // One full viewport height of travel end: "+=100vh" ``` **Pin spacing defaults to `true`.** If pinned content is overlapping the next section unexpectedly, confirm `pinSpacing` isn't set to `false`. The default adds padding to preserve document flow. **Clean up markers before shipping.** Remove the `markers` option from live code. During teardown, `Motion.cleanup()` detaches active ScrollTriggers and safely removes their marker and spacer nodes. --- Related: [Scroll Trigger](/docs/sdk/scroll-trigger) · [Performance Tips](/docs/sdk/performance) --- ## Debugging Animations > Debug with markers, console logging, timeline inspection, and common pitfalls. URL: https://motion.page/docs/sdk/debugging Animations are invisible by nature — when something goes wrong there's no error to catch, just an element that didn't move. This guide covers the tools available for diagnosing what went wrong. --- ## Scroll Trigger Markers The fastest way to debug a scroll-triggered animation is to visualise its trigger boundaries. Pass `markers: true` to `.onScroll()` and the SDK renders coloured lines showing exactly where `start` and `end` fire relative to both the element and the viewport. ```typescript import { Motion } from "@motion.page/sdk"; Motion("debug-reveal", ".section", { from: { opacity: 0, y: 40 }, duration: 0.6, ease: "power2.out", }).onScroll({ start: "top 80%", end: "bottom 20%", markers: true, }); ``` Two pairs of lines appear in the viewport: - **scroller-start / scroller-end** — the trigger positions on the viewport (or scroll container) - **start / end** — the corresponding positions on the target element If the animation never fires, the start line is likely never crossed. Adjust `start` until the lines align with your intent. **Remove `markers: true` before shipping.** Markers inject DOM nodes that remain visible to users if left in production. ### Custom Marker Colours When debugging multiple triggers on the same page, colour-coding them makes the markers distinguishable: ```typescript Motion("hero-reveal", ".hero", { from: { opacity: 0 }, duration: 0.8, }).onScroll({ start: "top 70%", markers: { startColor: "lime", endColor: "red", fontSize: "12px", indent: 40, }, }); Motion("card-reveal", ".card", { from: { opacity: 0, y: 30 }, duration: 0.5, }).onScroll({ each: true, start: "top 85%", markers: { startColor: "cyan", endColor: "orange", fontSize: "12px", indent: 80, }, }); ``` | Marker option | Type | Description | |---------------|------|-------------| | `startColor` | `string` | CSS colour for the start line label | | `endColor` | `string` | CSS colour for the end line label | | `fontSize` | `string` | Label font size (e.g. `"12px"`) | | `indent` | `number` | Horizontal offset in px — stagger triggers so they don't overlap | --- ## Inspecting a Timeline via `Motion("name")` Every timeline is stored in a named registry. Retrieve any timeline by name using the single-argument overload and inspect its state in the console at any point. ```typescript import { Motion } from "@motion.page/sdk"; // Create the timeline Motion("hero", "#hero", { from: { opacity: 0, y: 40 }, duration: 0.8, ease: "power2.out", }).onPageLoad({ paused: true }); // Retrieve and inspect const tl = Motion("hero"); console.log(tl.getName()); // "hero" console.log(tl.duration()); // 0.8 console.log(tl.progress()); // 0–1 console.log(tl.time()); // seconds elapsed console.log(tl.isActive()); // true if currently animating console.log(tl.timeScale()); // playback speed (1 = normal) ``` Use `Motion.get()` for safe retrieval that returns `undefined` instead of throwing when the timeline doesn't exist: ```typescript const tl = Motion.get("hero"); if (tl) { console.log(`progress: ${tl.progress().toFixed(3)}`); console.log(`time: ${tl.time().toFixed(3)}s / ${tl.duration()}s`); console.log(`active: ${tl.isActive()}`); } ``` ### Listing all registered timelines `Motion.getNames()` returns an array of every currently registered timeline name. Log it at any point to see exactly what's alive: ```typescript console.log(Motion.getNames()); // ["hero", "nav-reveal", "card-hover", "footer-fade"] ``` This is useful for catching orphaned timelines in SPAs — if names from previous routes are still listed after navigation, the cleanup logic didn't run. --- ## Console Logging with Callbacks ### `onUpdate` — log progress every frame Chain `.onUpdate()` on a timeline to receive `progress` (0–1) and `time` (seconds) on every animation frame. Use it to observe exactly how the animation advances. ```typescript import { Motion } from "@motion.page/sdk"; Motion("loader", ".progress-bar", { to: { width: "100%" }, duration: 2, }) .onUpdate((progress, time) => { console.log(`progress: ${(progress * 100).toFixed(1)}% time: ${time.toFixed(3)}s`); }) .onPageLoad(); ``` ### `onStart` and `onComplete` — confirm the timeline fired ```typescript import { Motion } from "@motion.page/sdk"; Motion("reveal", ".hero", { from: { opacity: 0, y: 30 }, duration: 0.7, }) .onStart(() => console.log("[reveal] started")) .onComplete(() => console.log("[reveal] complete")) .onPageLoad(); ``` If `[reveal] started` never appears in the console, the trigger condition was never met — the page-load event didn't fire (unusual), or a scroll/hover trigger was never activated. ### Per-animation `onUpdate` Individual animations inside a timeline each expose their own `onUpdate` via `AnimationConfig`. This fires relative to that animation's own 0–1 progress — useful for multi-step timelines where you want to observe one step in isolation: ```typescript import { Motion } from "@motion.page/sdk"; Motion("sequence", [ { target: "#title", from: { opacity: 0 }, duration: 0.5 }, { target: "#subtitle", from: { opacity: 0, y: 20 }, duration: 0.4, position: "+=0.1", onUpdate: (progress) => { console.log(`subtitle progress: ${(progress * 100).toFixed(1)}%`); }, }, ]).onPageLoad(); ``` --- ## `Motion.refreshScrollTriggers()` **`Motion.refreshScrollTriggers()`** recalculates every scroll trigger's start and end positions. Call it after any layout change that happens after the triggers were created. ```typescript import { Motion } from "@motion.page/sdk"; Motion.refreshScrollTriggers(); ``` This is the fix for scroll triggers that fire at the wrong scroll position after the page layout shifts. See [Common Pitfalls](#common-pitfalls) below for specific scenarios. ### After dynamic content loads ```typescript async function loadProducts() { const products = await fetchProducts(); renderGrid(products); // new DOM added — page is now taller // Recalculate all trigger positions Motion.refreshScrollTriggers(); } ``` ### After an accordion opens ```typescript document.querySelectorAll(".accordion-toggle").forEach((toggle) => { toggle.addEventListener("click", () => { toggle.closest(".accordion")?.classList.toggle("open"); // Heights have changed — refresh immediately Motion.refreshScrollTriggers(); }); }); ``` ### Debounced resize handler Scroll positions are also invalidated by window resizes. Debounce the refresh to avoid excessive recalculations: ```typescript import { Motion } from "@motion.page/sdk"; let resizeTimer: ReturnType; window.addEventListener("resize", () => { clearTimeout(resizeTimer); resizeTimer = setTimeout(() => { Motion.refreshScrollTriggers(); }, 150); }); ``` --- ## Common Pitfalls ### Elements not found — selector timing Animations that target elements not yet in the DOM are silently ignored. The SDK resolves selectors at the moment `.onPageLoad()` / `.onScroll()` / etc. is called. ```typescript // ❌ Script runs before the DOM is parsed — selector finds nothing Motion("hero", "#hero", { from: { opacity: 0 }, duration: 0.8 }).onPageLoad(); ``` The fix is to ensure your script runs after the DOM is ready. `.onPageLoad()` waits for `DOMContentLoaded` and fires immediately if the event has already passed — but the selector is still resolved at call time. Place scripts at the end of ``, use `defer`, or wrap in a `DOMContentLoaded` listener: ```typescript // ✅ Script deferred — DOM is fully parsed when this runs document.addEventListener("DOMContentLoaded", () => { Motion("hero", "#hero", { from: { opacity: 0, y: 40 }, duration: 0.8, }).onPageLoad(); }); ``` In frameworks, initialise inside the component's mount lifecycle rather than at module scope: ```typescript // ✅ React — DOM is available inside useEffect import { useEffect } from "react"; import { Motion } from "@motion.page/sdk"; function HeroSection() { useEffect(() => { const ctx = Motion.context(() => { Motion("hero", "#hero", { from: { opacity: 0, y: 40 }, duration: 0.8 }).onPageLoad(); }); return () => ctx.revert(); }, []); return
{/* ... */}
; } ``` --- ### FOUC — flash of unstyled content Elements that animate in from `opacity: 0` or an offset position will flash at their natural CSS state for a frame before the animation initialises. This is the browser rendering the element before the SDK has applied its initial state. The fix is `Motion.set()` — apply the initial state immediately, synchronously, so the element is already hidden when the browser first paints it: ```typescript import { Motion } from "@motion.page/sdk"; // Apply initial state before the browser first paints Motion.set(".hero-title, .hero-sub, .hero-cta", { opacity: 0, y: 30 }); // Then register the animation Motion("hero", [ { target: ".hero-title", to: { opacity: 1, y: 0 }, duration: 0.7 }, { target: ".hero-sub", to: { opacity: 1, y: 0 }, duration: 0.6, position: "+=0.1" }, { target: ".hero-cta", to: { opacity: 1, y: 0 }, duration: 0.5, position: "+=0.1" }, ]).onPageLoad(); ``` Alternatively, set the initial state in CSS and use `from`-only animation — the SDK reads the computed CSS as the endpoint: ```css /* Elements start invisible in CSS — no flash */ .hero-title, .hero-sub, .hero-cta { opacity: 0; transform: translateY(30px); } ``` ```typescript // from-only — SDK animates FROM these values TO the natural CSS state Motion("hero", ".hero-title, .hero-sub, .hero-cta", { from: { opacity: 0, y: 30 }, duration: 0.7, stagger: 0.12, }).onPageLoad(); ``` --- ### Scroll trigger not updating after layout changes Scroll triggers calculate their positions once at initialisation. If the page layout changes afterwards — fonts render and reflow text, images load and push content down, accordions open, or dynamic content is injected — the calculated positions become stale. Triggers fire too early or too late. **Symptom:** The animation triggers at the wrong scroll position, or a `markers: true` line is visually misaligned with the element it should track. **Fix:** Call `Motion.refreshScrollTriggers()` after any layout-affecting change: ```typescript import { Motion } from "@motion.page/sdk"; // After images load window.addEventListener("load", () => { // All images and fonts have loaded — layout is stable Motion.refreshScrollTriggers(); }); // After a tab panel becomes visible document.querySelectorAll(".tab").forEach((tab) => { tab.addEventListener("click", () => { showTabPanel(tab); Motion.refreshScrollTriggers(); }); }); // After lazy-loaded content is injected observer.observe(sentinel); function onContentLoaded(newItems: Element[]) { appendToPage(newItems); Motion.refreshScrollTriggers(); } ``` If you're working with a CMS, page builder, or content that loads progressively, calling `Motion.refreshScrollTriggers()` on `window.load` is a reliable safety net that catches most layout-affecting async loads in one call. --- ## Quick Diagnostic Checklist | Symptom | Likely cause | Fix | |---------|-------------|-----| | Animation never plays | Selector not found, trigger condition not met | Check `Motion.getNames()`, add `onStart` log, verify selector in DevTools | | Elements flash before animating | Initial state not applied before first paint | Use `Motion.set()` or set initial state in CSS | | Scroll trigger fires at wrong position | Layout changed after initialisation | Call `Motion.refreshScrollTriggers()` after layout changes | | Two timelines stepping on each other | Same name used twice — second appends to first | Call `Motion("name").kill()` before re-registering | | Timeline not found on retrieval | Timeline was killed or never created | Guard with `Motion.has("name")` or use `Motion.get("name")` | | Animation plays but wrong element moves | Selector matches more than expected | Inspect matched elements with `Motion.utils.toArray(".selector")` | ```typescript // Inspect what a selector actually matches at runtime import { Motion } from "@motion.page/sdk"; const matched = Motion.utils.toArray(".my-selector"); console.log(`matched ${matched.length} elements:`, matched); ``` --- Related: [Scroll Trigger](/docs/sdk/scroll-trigger) · [Timeline Control](/docs/sdk/timeline-control) · [Static Methods](/docs/sdk/static-methods) --- ## What is Motion.page SDK > Overview of the SDK — a standalone animation engine with named timelines, triggers, and zero dependencies. URL: https://motion.page/docs/sdk/overview import LivePreview from "../../components/docs/blocks/LivePreview"; **Motion.page SDK** (`@motion.page/sdk`) is a standalone animation engine purpose-built for the web. It gives you named timelines, implicit value resolution, built-in triggers, and zero dependencies to manage. ## Install ```bash bun add @motion.page/sdk # or npm install @motion.page/sdk ``` ## Quick Start ```typescript import { Motion } from "@motion.page/sdk"; Motion("fade-in", ".hero", { from: { opacity: 0, y: 40 }, duration: 0.6, ease: "power2.out", }).play(); ``` Motion.page SDK

Animate with intent.

Named timelines, implicit values, and triggers in one small API.

zero dependenciesweb native
`} css={`.hero { width: min(470px, 100%); padding: 26px; border: 1px solid rgba(153, 102, 255, 0.3); border-radius: 20px; background: radial-gradient(circle at top right, rgba(102, 51, 238, 0.24), transparent 48%), #11152a; box-shadow: 0 24px 70px rgba(0, 0, 0, 0.3); } .eyebrow { color: #a98dff; font-size: 11px; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; } h2 { margin-top: 9px; color: white; font-size: clamp(25px, 5vw, 38px); line-height: 1; } p { margin-top: 11px; color: #9ea4c1; font-size: 13px; line-height: 1.5; } .hero-meta { display: flex; gap: 8px; margin-top: 18px; } .hero-meta span { padding: 5px 8px; border-radius: 999px; background: rgba(255, 255, 255, 0.06); color: #c7cae0; font-size: 10px; }`} js={`import { Motion } from "https://cloud.motion.page/sdk/latest.js"; Motion("overview-fade-in", ".hero", { from: { opacity: 0, y: 40 }, duration: 0.6, ease: "power2.out", repeat: { times: -1, yoyo: true, delay: 0.6 }, }).play();`} /> Three arguments: a **name** for the timeline, a **target** selector or element, and an **animation config**. Call `.play()` and you're done. --- ## Why Motion.page SDK? ### Named timelines `Motion('name')` always returns the **same `Timeline` instance**. Call it from anywhere in your app to reference, control, or extend the same animation — no need to store references in variables. ```typescript // Create Motion("hero", ".hero", { from: { opacity: 0 } }).play(); // Control from anywhere Motion("hero").pause(); Motion("hero").reverse(); ``` ### Implicit value resolution Only specify `from` **or** `to` — the SDK reads the missing endpoint from the element's **current computed CSS**. No need to hard-code both states. ```typescript // Animates FROM opacity 0 TO whatever the element currently has Motion("reveal", ".card", { from: { opacity: 0, y: 30 } }).play(); ``` ### Built-in triggers Chain triggers directly on timelines instead of wiring up listeners manually: ```typescript Motion("on-scroll", ".section", { from: { opacity: 0 } }).onScroll(); Motion("on-hover", ".button", { to: { scale: 1.05 } }).onHover({ each: true, onLeave: "reverse" }); Motion("on-click", ".panel", { to: { height: "auto" } }).onClick({ each: true }); Motion("on-load", ".hero", { from: { y: -20 } }).onPageLoad(); ``` Available triggers: `.onScroll()`, `.onHover()`, `.onClick()`, `.onPageLoad()`, `.onPageExit()`, `.onMouseMove()`, `.onGesture()`, `.onCursor()` ### Text splitting built-in Animate by `chars`, `words`, or `lines` with an optional mask effect — no extra plugin or setup: ```typescript Motion("headline", "h1", { from: { opacity: 0, y: "100%" }, split: "words", stagger: 0.05, }).onPageLoad(); ``` ### FLIP animations built-in Morph an element from one position/size to another using the `fit` property: ```typescript Motion("morph", ".card", { fit: { target: ".destination" }, }).onClick({ target: ".card" }); ``` ### SVG drawing built-in Animate SVG strokes with `drawSVG` — no plugin needed: ```typescript Motion("draw", "path", { from: { drawSVG: "0%" }, to: { drawSVG: "100%" }, duration: 1.2, }).onScroll(); ``` --- ## Zero Dependencies The SDK is a **self-contained animation engine** with no external dependencies. Install the SDK and you're ready — your bundler doesn't need to resolve anything else. --- ## Bundle Formats | File | Format | Usage | |------|--------|-------| | `dist/index.js` | ESM | `import { Motion } from '@motion.page/sdk'` | | `dist/index.cjs` | CommonJS | `require('@motion.page/sdk')` | | `dist/motion-sdk.browser.js` | IIFE | ` ``` Then add a player without `autoplay` or `loop`; Motion.page will own its frame position: ```html ``` The LottieFiles player exposes its lottie-web `AnimationItem` as `element._lottie`. That object provides `totalFrames` and `goToAndStop()`. ## Basic Usage Create a small seek helper, then call it from `onUpdate`: ("#hero-lottie")!; function seekLottie( element: LottieElement, progress: number, start = 0, end = 1 ) { const animation = element._lottie; if (!animation) return; const lastFrame = animation.totalFrames - 1; const normalized = start + (end - start) * progress; animation.goToAndStop(Math.round(normalized * lastFrame), true); } Motion("hero-lottie", player, { duration: 2, ease: "none", onUpdate: (progress) => seekLottie(player, progress), }).onPageLoad();`} />
onUpdate(progress) goToAndStop(frame, true)
`} css={`.lottie-demo { width: min(640px, 100%); border: 1px solid rgba(143, 158, 255, 0.22); border-radius: 20px; overflow: hidden; background: #080b20; } .lottie-player { position: relative; height: 275px; padding: 18px; background: radial-gradient(circle at 50% 45%, rgba(83, 93, 225, 0.2), transparent 48%), #050713; } .lottie-player svg { display: block; width: 100%; height: 100%; overflow: visible; } .guide, .progress-ring { fill: none; stroke-width: 2; } .guide { stroke: rgba(153, 169, 255, 0.13); } .progress-ring { stroke: #7d6cff; stroke-linecap: round; stroke-dasharray: 452.4; stroke-dashoffset: 452.4; } .satellite-glow { fill: rgba(87, 218, 255, 0.18); } .satellite { fill: #7cecff; } .core { filter: drop-shadow(0 16px 28px rgba(69, 94, 255, 0.4)); } .spark { fill: none; stroke: rgba(255, 255, 255, 0.82); stroke-width: 4; stroke-linecap: round; stroke-linejoin: round; } .orbit { transform-origin: 160px 110px; } .lottie-hud { position: absolute; top: 15px; right: 15px; padding: 7px 10px; border: 1px solid rgba(173, 184, 255, 0.24); border-radius: 999px; background: rgba(5, 7, 20, 0.72); display: flex; align-items: baseline; gap: 5px; color: #858eaf; font-size: 9px; letter-spacing: 0.08em; } .lottie-hud strong { color: #f4f5ff; font-size: 13px; } .lottie-caption { padding: 18px 20px; display: flex; align-items: center; justify-content: space-between; gap: 18px; color: #7e88ad; font-size: 12px; } .lottie-caption strong { color: #dfe2ff; } @media (max-width: 520px) { .lottie-player { height: 245px; } .lottie-caption { align-items: flex-start; flex-direction: column; gap: 6px; } }`} js={`import { Motion } from "https://cloud.motion.page/sdk/latest.js"; const player = document.querySelector(".lottie-player"); const orbit = document.querySelector(".orbit"); const core = document.querySelector(".core"); const ring = document.querySelector(".progress-ring"); const frameLabel = document.querySelector(".lottie-frame"); player._lottie = { totalFrames: 90, goToAndStop(frame) { const progress = frame / (this.totalFrames - 1); const pulse = 42 + Math.sin(progress * Math.PI * 4) * 5; orbit.setAttribute("transform", "rotate(" + progress * 360 + " 160 110)"); core.setAttribute("r", String(pulse)); ring.style.strokeDashoffset = String(452.4 * (1 - progress)); frameLabel.textContent = String(frame).padStart(3, "0"); }, }; Motion("lottie-preview", player, { duration: 2.4, ease: "none", onUpdate: (progress) => { const animation = player._lottie; const lastFrame = animation.totalFrames - 1; animation.goToAndStop(Math.round(progress * lastFrame), true); }, repeat: { times: -1, yoyo: true, delay: 0.6 }, }).play();`} /> The preview uses a tiny local player stub so it stays self-contained. Its `totalFrames` and `goToAndStop()` contract is the same one used by the LottieFiles player and by Builder-generated code. ## Scroll-Synced Playback Attach `.onScroll({ scrub: true })` to map scroll position directly to the frame range: ```typescript Motion("scroll-lottie", player, { duration: 1, ease: "none", onUpdate: (progress) => seekLottie(player, progress), }).onScroll({ target: "#hero-lottie", start: "top center", end: "bottom center", scrub: true, }); ``` Scrolling backward naturally sends decreasing progress values, so the Lottie follows in reverse without additional event handling. ## Partial Frame Range Pass normalized start and end values to the helper. This plays the middle half of the animation: ```typescript Motion("partial-lottie", player, { duration: 1, ease: "none", onUpdate: (progress) => seekLottie(player, progress, 0.25, 0.75), }).onScroll({ target: "#hero-lottie", scrub: true }); ``` | Start | End | Result | |-------|-----|--------| | `0` | `1` | First frame → last frame | | `0.25` | `0.75` | 25% frame → 75% frame | | `1` | `0` | Last frame → first frame | | `0.75` | `0.25` | 75% frame → 25% frame | Reversing is simply a range whose start is greater than its end; no separate SDK option is required. ## Hover and Click The same frame bridge works with interaction triggers: ```typescript Motion("lottie-hover", ".card-lottie", { duration: 0.7, ease: "none", onUpdate: (progress) => { document.querySelectorAll(".card-lottie").forEach((element) => { seekLottie(element, progress); }); }, }).onHover({ each: true, onLeave: "reverse" }); ``` For independent players, keep `each: true` so hovering one card does not drive every card's timeline instance. ## Builder-Generated Bridge When Lottie is enabled in the Builder, its SDK generator converts the normalized frame range and reverse toggle into an animation-level callback equivalent to: ```typescript onUpdate: (progress) => { document.querySelectorAll("#hero-lottie").forEach((element) => { if (!element._lottie) return; const lastFrame = element._lottie.totalFrames - 1; const frame = Math.round(0 * lastFrame + (1 - 0) * lastFrame * progress); element._lottie.goToAndStop(frame, true); }); } ``` This callback is generated from Builder metadata. Handwritten core SDK code should use `onUpdate` directly rather than adding a root-level `lottie` object. ## Limitations - **Wait for the player to initialize.** The guarded helper safely skips ticks until `element._lottie` exists. - **Use one animation callback per player target.** An `AnimationConfig` has one `onUpdate` function; combine additional progress-driven work inside that function. - **Do not enable autoplay.** A player running on its own clock will fight the timeline's frame seeking. - **Refresh scroll positions after layout shifts.** Call `Motion.refreshScrollTriggers()` if loading the player changes document geometry after trigger creation. ## Related - [Scroll Trigger](/docs/sdk/scroll-trigger) — drive frames with scroll position - [Scroll Trigger Advanced](/docs/sdk/scroll-trigger-advanced) — pin and scrub a full-page Lottie story - [Timeline Control](/docs/sdk/timeline-control) — react to timeline-level progress and lifecycle events --- ## Observer & Gesture > Respond to pointer, touch, wheel, and scroll gestures with 21 callback events. URL: https://motion.page/docs/sdk/observer-gesture import CodeBlock from "../../components/docs/blocks/CodeBlock.astro"; import LivePreview from "../../components/docs/blocks/LivePreview"; `.onGesture()` turns any timeline into a gesture-driven animation. It listens to pointer, touch, wheel, and scroll inputs and fires one of 21 callback events — letting you map gestures to animation actions like `play`, `reverse`, `progressUp`, or `playNext`. ## Basic Usage ```typescript import { Motion } from "@motion.page/sdk"; Motion("swipe-panel", ".panel", { to: { x: "-100%" }, duration: 0.4, ease: "power2.inOut", }).onGesture({ types: ["pointer", "touch"], events: { LeftComplete: "play", RightComplete: "reverse", }, }); ``` --- ## Options | Option | Type | Default | Description | |--------|------|---------|-------------| | `types` | `GestureInputType[]` | — | **Required.** One or more input types to observe: `'pointer'`, `'touch'`, `'wheel'`, `'scroll'` | | `events` | `Partial>` | — | **Required.** Map of event names to animation actions | | `target` | `string \| Element` | `window` | Element to observe. Defaults to window for global gestures. Use a selector for element-scoped gestures | | `tolerance` | `number` | `1` | Minimum distance in pixels before a gesture is recognized | | `dragMinimum` | `number` | `10` | Minimum drag distance before drag events fire. Applies to `pointer` and `touch` types | | `wheelSpeed` | `number` | `1` | Sensitivity multiplier for wheel events | | `scrollSpeed` | `number` | `1` | Sensitivity multiplier for scroll events | | `stopDelay` | `number` | `150` | **Milliseconds** of inactivity before the `Stop` event fires | | `smooth` | `number` | `0` | Smoothing factor 0–1. Higher values add lag to progress-based actions | | `animationStep` | `number \| Partial>` | `0.1` | How much of the timeline to step per gesture (0.1 = 10%). Pass an object to set per-event steps | | `preventDefault` | `boolean` | `false` | Call `event.preventDefault()` on observed events. Makes listeners non-passive — required for blocking native scroll on wheel/touch | | `lockAxis` | `boolean` | `false` | Lock gesture detection to the dominant axis, preventing diagonal gestures from firing both axes | | `each` | `boolean` | `false` | Create an independent timeline instance per matched element | > **`stopDelay` is in milliseconds**, not seconds. The default `150` means 150ms of inactivity triggers `Stop`. This is unlike other time values in the SDK which use seconds. --- ## Gesture Types | Type | Detects | Use for | |------|---------|---------| | `'pointer'` | Mouse movement and clicks | Desktop swipe detection, drag interactions, hover-based gestures | | `'touch'` | Touchscreen swipes and taps | Mobile swipe navigation, touch drag interfaces | | `'wheel'` | Mouse wheel / trackpad scroll | Wheel-driven animation progress, custom scrolljacking | | `'scroll'` | Native page scroll direction | Direction-aware scroll reactions (distinct from position-based ScrollTrigger) | You can combine multiple types in one trigger: ```typescript // Respond to both touch and pointer with the same callbacks .onGesture({ types: ["pointer", "touch"], events: { Up: "play", Down: "reverse" }, }) ``` --- ## Event Reference Events are the keys in the `events` map. They describe **what gesture was detected** and **when** within the gesture lifecycle. ### Direction Events These fire **continuously** while the gesture moves in that direction. | Event | Fires when | |-------|-----------| | `Up` | Gesture is actively moving upward | | `Down` | Gesture is actively moving downward | | `Left` | Gesture is actively moving left | | `Right` | Gesture is actively moving right | ### Direction Complete Events These fire **once on release** for every direction that crossed `tolerance` during the gesture. A gesture that reverses can therefore fire more than one directional complete event. Use `lockAxis: true` when only one axis should qualify. | Event | Fires when | |-------|-----------| | `UpComplete` | Upward movement was activated before release | | `DownComplete` | Downward movement was activated before release | | `LeftComplete` | Leftward movement was activated before release | | `RightComplete` | Rightward movement was activated before release | ### Change Events | Event | Fires when | |-------|-----------| | `Change` | Any gesture movement occurs (any direction) | | `ChangeX` | Horizontal movement detected | | `ChangeY` | Vertical movement detected | ### Toggle Events Toggle events fire once per direction change — when the gesture **switches** from moving in one direction to another. | Event | Fires when | |-------|-----------| | `ToggleX` | Gesture changes horizontal direction (left ↔ right) | | `ToggleY` | Gesture changes vertical direction (up ↔ down) | ### Press & Drag Events | Event | Fires when | |-------|-----------| | `PressInit` | Immediately on press, before start position is recorded. No delta available yet | | `Press` | After start position is recorded (delta is available) | | `Release` | On pointer/touch release | | `Drag` | During active drag movement (after `dragMinimum` threshold is exceeded) | | `DragEnd` | When drag movement ends | ### Stop & Hover Events | Event | Fires when | |-------|-----------| | `Stop` | Once after `stopDelay` ms of inactivity. Useful for "idle" state resets | | `Hover` | Pointer enters the `target` element. **Requires a `target` element — does nothing on `window`** | | `HoverEnd` | Pointer leaves the `target` element. **Requires a `target` element — does nothing on `window`** | --- ## Action Reference Actions are the values in the `events` map. They control what the timeline does when that event fires. | Action | Description | |--------|-------------| | `'play'` | Play the timeline forward | | `'pause'` | Pause playback at current position | | `'reverse'` | Play the timeline backward | | `'restart'` | Seek to start and play forward | | `'toggle'` | Toggle between play and pause | | `'reset'` | Jump instantly to the start without playing | | `'complete'` | Jump instantly to the end without playing | | `'kill'` | Stop and remove the timeline | | `'playReverse'` | Smart alternate: if at start, play forward; if at end, reverse | | `'progressUp'` | Step the timeline forward by `animationStep` | | `'progressDown'` | Step the timeline backward by `animationStep` | | `'playNext'` | Play the next element's timeline. **Requires `each: true`** | | `'playPrevious'` | Play the previous element's timeline. **Requires `each: true`** | --- ## Observer vs. ScrollTrigger Both `.onGesture()` and `.onScroll()` can react to scrolling, but they work very differently: | | `.onGesture()` with `'scroll'` | `.onScroll()` | |--|-------------------------------|---------------| | **Based on** | Scroll **direction** | Scroll **position** | | **Fires** | When user scrolls up or down | When element reaches a scroll position | | **Typical use** | Navigation, slide transitions, direction-aware effects | Reveal animations, parallax, scrubbing | | **Progress tied to scroll?** | No (unless using `progressUp`/`progressDown`) | Yes (with `scrub: true`) | | **Works with non-scroll inputs?** | Yes (pointer, touch, wheel) | No | Use `.onGesture()` when you care about **which way** the user is moving. Use [Scroll Trigger](/docs/sdk/scroll-trigger) when you care about **where** they've scrolled to. --- ## Swipe Detection Detect completed swipe gestures using the `*Complete` events. These fire once per swipe when the finger or pointer lifts — not continuously during movement. --- ## Wheel-Driven Animation Progress Use `progressUp` / `progressDown` with the `'wheel'` type to scrub a timeline with the mouse wheel — without locking the page scroll position the way `onScroll` with `scrub` does. Per-event step sizes let you make one direction faster than the other: ```typescript .onGesture({ types: ["wheel"], events: { Up: "progressUp", Down: "progressDown" }, animationStep: { Up: 0.15, Down: 0.05 }, }) ``` --- ## Touch Swipe with Live Demo
Swipe me left or right
← swipe left  |  swipe right →
`} css={`.scene { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; gap: 20px; font-family: sans-serif; user-select: none; } .track { overflow: hidden; width: 260px; border-radius: 14px; } .card { width: 260px; height: 120px; background: linear-gradient(135deg, #6633EE, #9966FF); border-radius: 14px; display: flex; align-items: center; justify-content: center; color: white; font-size: 15px; font-weight: 600; cursor: grab; } .hint { color: #888; font-size: 13px; }`} js={`import { Motion } from "https://cloud.motion.page/sdk/latest.js"; Motion("swipe", "#card", { to: { x: "-120%", opacity: 0 }, duration: 0.35, ease: "power3.out", }).onGesture({ target: ".track", types: ["touch", "pointer"], events: { LeftComplete: "play", RightComplete: "reverse", }, tolerance: 40, dragMinimum: 40, preventDefault: true, });`} /> --- ## Slide Gallery with `playNext` / `playPrevious` `playNext` and `playPrevious` advance or retreat through a set of per-element timelines. They **require `each: true`** — the gesture drives each element's own timeline in sequence. --- ## Scroll Direction Reactions Use the `'scroll'` type to fire animations based on the **direction** the user scrolls — not their scroll position. A common pattern: hide a navbar on scroll down, reveal it on scroll up. --- ## `Stop` Event — Idle Reset The `Stop` event fires once after `stopDelay` milliseconds of inactivity. Use it to reset the animation when the user stops interacting. ```typescript Motion("progress-bar", ".bar", { to: { scaleX: 1 }, duration: 1, ease: "none", }).onGesture({ types: ["wheel"], events: { Up: "progressUp", Down: "progressDown", Stop: "reverse", // Rewind bar when wheel stops }, stopDelay: 800, // Wait 800ms of stillness before firing Stop animationStep: 0.04, }); ``` --- ## Hover Gestures on Elements `Hover` and `HoverEnd` fire when the pointer enters and leaves the **`target`** element. They silently do nothing if `target` is `window`. ```typescript Motion("card-glow", ".card", { to: { boxShadow: "0 0 32px rgba(102, 51, 238, 0.6)", y: -6 }, duration: 0.3, ease: "power2.out", }).onGesture({ target: ".card", types: ["pointer"], events: { Hover: "play", HoverEnd: "reverse", }, }); ``` > For hover effects this simple, `.onHover()` is a cleaner choice. Use gesture `Hover`/`HoverEnd` events when you need them **alongside** other gesture callbacks in one config. --- ## `each` — Independent Per-Element Instances Without `each`, all matched elements share one timeline. With `each: true`, every element gets its own independent instance — essential for gallery navigation with `playNext`/`playPrevious`. ```typescript // Without each — all cards animate together on any swipe Motion("cards", ".card", { from: { opacity: 0, x: 60 }, duration: 0.5, }).onGesture({ types: ["touch"], events: { LeftComplete: "play" }, }); // With each — every card has its own state Motion("cards", ".card", { from: { opacity: 0, x: 60 }, duration: 0.5, }).onGesture({ each: true, types: ["touch"], events: { LeftComplete: "playNext", RightComplete: "playPrevious", }, }); ``` --- ## Tips & Gotchas **`stopDelay` is in milliseconds.** The default `150` means 150ms. Do not pass `1.5` expecting 1.5 seconds — pass `1500`. **`Hover` / `HoverEnd` require a `target` element.** They silently do nothing when `target` is omitted (which defaults to `window`). Always provide a `target` selector for hover events. **`preventDefault: true` makes listeners non-passive.** Required to block native browser scroll when using `wheel` or `touch` types. Without it, calling `event.preventDefault()` would throw a warning in modern browsers. **`lockAxis: true` prevents diagonal gestures.** The first axis of movement is locked in. Useful for sliders and galleries where you want clean horizontal or vertical gestures only. **`playNext` / `playPrevious` only work with `each: true`.** Without per-element timelines, there is no concept of "next" or "previous" to step through. --- ## Related - [Scroll Trigger](/docs/sdk/scroll-trigger) — position-based scroll animations with scrub, pin, and snap - [Scroll Trigger Advanced](/docs/sdk/scroll-trigger-advanced) — pinning, horizontal scroll, and snap - [Click Trigger](/docs/sdk/click) — toggle animations on click --- ## Scale > Scale elements uniformly or independently on X/Y axes. URL: https://motion.page/docs/sdk/scale import LivePreview from "../../components/docs/blocks/LivePreview"; Scale controls how large or small an element appears during an animation. Use `scale` for uniform scaling, or `scaleX` / `scaleY` for independent axis control. All three are unitless — `1` means normal size. ## Basic Scale Pass `scale` inside `from` or `to`. A value of `1` is the element's natural size. ```typescript import { Motion } from "@motion.page/sdk"; Motion("grow", ".box", { from: { scale: 0 }, duration: 0.5, ease: "back.out(1.7)", }).onPageLoad(); ``` Because `scale: 1` is a **natural CSS default**, omitting `to` is correct here — the SDK resolves the missing endpoint from the element's current computed style and animates to full size automatically.
`} css={`.scene { display: flex; align-items: center; justify-content: center; height: 100%; } .box { width: 80px; height: 80px; background: #6633EE; border-radius: 12px; }`} js={`import { Motion } from "https://cloud.motion.page/sdk/latest.js"; Motion("scale-in", ".box", { from: { scale: 0 }, duration: 0.6, ease: "back.out", repeat: { times: -1, yoyo: true, delay: 0.6 }, }).play();`} /> ## Common Values | Value | Result | |-------|--------| | `0` | Invisible (zero size) | | `0.5` | Half size | | `1` | Normal size (natural default) | | `1.5` | 150% size | | `2` | Double size | ## Independent Axes — scaleX and scaleY Use `scaleX` and `scaleY` when you want non-uniform scaling — stretching or squashing on one axis only. ```typescript // Squash horizontally Motion("squash", ".box", { to: { scaleX: 0.6, scaleY: 1.2 }, duration: 0.3, ease: "power2.inOut", }).onHover({ each: true, onLeave: "reverse" }); ``` ## Common Patterns ### Zoom In on Scroll Reveal an element by scaling it in as it enters the viewport. ```typescript Motion("zoom-in", ".card", { from: { scale: 0.8, opacity: 0 }, duration: 0.6, ease: "power2.out", }).onScroll({ scrub: false, toggleActions: "play none none none" }); ``` ### Pop In with Opacity Combine `scale` and `opacity` for a soft, polished entrance effect. ```typescript Motion("pop-in", ".modal", { from: { scale: 0.9, opacity: 0 }, duration: 0.4, ease: "back.out(1.4)", }).onPageLoad(); ``` The two properties animate in parallel — the element fades in while simultaneously growing to its natural size. ### Pulse (Infinite Repeat with Yoyo) Gently pulse an element to draw attention. ```typescript Motion("pulse", ".badge", { to: { scale: 1.15 }, duration: 0.6, ease: "power1.inOut", repeat: { times: -1, yoyo: true }, }).onPageLoad(); ``` `yoyo: true` reverses the animation on each cycle, producing a continuous breathe effect without any visible jump. ### Rubber Band (scaleX + scaleY) Animate `scaleX` and `scaleY` in opposite directions for a rubbery, organic feel. ```typescript import { Motion } from "@motion.page/sdk"; Motion("rubber-band", ".button", [ { target: ".button", to: { scaleX: 1.3, scaleY: 0.7 }, duration: 0.15, ease: "power2.out", }, { target: ".button", to: { scaleX: 0.85, scaleY: 1.15 }, duration: 0.1, ease: "power2.inOut", }, { target: ".button", to: { scaleX: 1.05, scaleY: 0.95 }, duration: 0.1, ease: "power2.inOut", }, { target: ".button", duration: 0.15, ease: "power2.out", }, ]).onClick({ each: true }); ``` ## Transform Origin Scale always happens **around the transform origin** — by default the element's center (`50% 50%`). Change this with `transformOrigin` to scale from a corner, edge, or any custom point. ```typescript // Scale up from the bottom-left corner Motion("corner-scale", ".card", { from: { scale: 0, transformOrigin: "0% 100%" }, duration: 0.5, ease: "power3.out", }).onPageLoad(); ``` See the [Transform Origin](/docs/sdk/transform-origin) page for all supported values and examples. --- ## Image Sequence > Drive frame-by-frame canvas sequences with Motion.page timeline progress. URL: https://motion.page/docs/sdk/image-sequence import CodeBlock from "../../components/docs/blocks/CodeBlock.astro"; import LivePreview from "../../components/docs/blocks/LivePreview"; An **image sequence** maps timeline progress from `0` to `1` onto numbered image frames and draws the current frame to a ``. It is a common pattern for scroll-driven product spins, cinematic reveals, and 3D rotations. > `imageSequence` is not a property of the core SDK's `AnimationConfig`. The Motion.page Builder owns the image loading runtime and connects it to generated SDK timelines with `onUpdate`. In handwritten SDK code, preload and draw the frames yourself, then use the supported `onUpdate` callback shown below. ## HTML Setup Give the canvas a stable CSS size and place it inside the section that will drive the scroll range: ```html
``` ```css .product-section { min-height: 300vh; } .sequence { display: block; width: 100%; aspect-ratio: 16 / 9; } ``` ## Basic Usage Preload the numbered files, draw the first frame, and let `AnimationConfig.onUpdate` select the frame for the current timeline progress: ("canvas.sequence")!; const context = canvas.getContext("2d")!; const urls = Array.from( { length: 120 }, (_, index) => "/images/product/" + String(index + 1).padStart(4, "0") + ".jpg" ); const loadImage = (src: string) => new Promise((resolve, reject) => { const image = new Image(); image.onload = () => resolve(image); image.onerror = reject; image.src = src; }); const frames = await Promise.all(urls.map(loadImage)); function drawFrame(progress: number) { const index = Math.round(progress * (frames.length - 1)); const image = frames[index]; context.clearRect(0, 0, canvas.width, canvas.height); context.drawImage(image, 0, 0, canvas.width, canvas.height); } drawFrame(0); const playhead = { progress: 0 }; Motion("product-spin", playhead, { to: { progress: 1 }, duration: 1, ease: "none", onUpdate: drawFrame, }).onScroll({ target: ".product-section", start: "top top", end: "+=300%", scrub: true, pin: true, });`} />
FRAME01/ 60
PROGRESS → FRAME INDEX Motion's onUpdate drives every redraw.
`} css={`.sequence-demo { width: min(680px, 100%); border: 1px solid rgba(143, 158, 255, 0.22); border-radius: 20px; overflow: hidden; background: #080b20; } .sequence-stage { position: relative; height: 260px; background: radial-gradient(circle at 50% 48%, rgba(82, 111, 255, 0.22), transparent 48%), #050713; } .sequence-canvas { display: block; width: 100%; height: 100%; } .frame-hud { position: absolute; top: 15px; right: 15px; padding: 7px 10px; border: 1px solid rgba(173, 184, 255, 0.24); border-radius: 999px; background: rgba(5, 7, 20, 0.72); display: flex; align-items: baseline; gap: 5px; color: #858eaf; font-size: 9px; letter-spacing: 0.08em; } .frame-hud strong { color: #f4f5ff; font-size: 13px; } .sequence-caption { padding: 18px 20px; display: flex; align-items: center; justify-content: space-between; gap: 18px; } .sequence-caption span { color: #7e88ad; font-size: 10px; font-weight: 800; letter-spacing: 0.12em; } .sequence-caption strong { color: #e9ebff; font-size: 13px; text-align: right; } @media (max-width: 520px) { .sequence-stage { height: 230px; } .sequence-caption { align-items: flex-start; flex-direction: column; } .sequence-caption strong { text-align: left; } }`} js={`import { Motion } from "https://cloud.motion.page/sdk/latest.js"; const canvas = document.querySelector(".sequence-canvas"); const context = canvas.getContext("2d"); const label = document.querySelector(".frame-number"); const frameCount = 60; function resizeCanvas() { const dpr = Math.min(devicePixelRatio, 2); canvas.width = Math.round(canvas.clientWidth * dpr); canvas.height = Math.round(canvas.clientHeight * dpr); } function renderFrame(progress) { const frame = Math.round(progress * (frameCount - 1)); const width = canvas.width; const height = canvas.height; const cx = width / 2; const cy = height / 2; const dpr = Math.min(devicePixelRatio, 2); const angle = progress * Math.PI * 2; context.clearRect(0, 0, width, height); const glow = context.createRadialGradient(cx, cy, 0, cx, cy, 120 * dpr); glow.addColorStop(0, "rgba(84, 126, 255, 0.34)"); glow.addColorStop(1, "rgba(84, 126, 255, 0)"); context.fillStyle = glow; context.fillRect(0, 0, width, height); context.save(); context.translate(cx, cy); context.rotate(angle * 0.12); context.scale(0.65 + Math.abs(Math.cos(angle)) * 0.35, 1); const body = context.createLinearGradient(-90 * dpr, -70 * dpr, 90 * dpr, 70 * dpr); body.addColorStop(0, "#d9e7ff"); body.addColorStop(0.32, "#607cff"); body.addColorStop(0.72, "#252b64"); body.addColorStop(1, "#0f1230"); context.fillStyle = body; context.beginPath(); context.roundRect(-95 * dpr, -67 * dpr, 190 * dpr, 134 * dpr, 36 * dpr); context.fill(); context.strokeStyle = "rgba(223, 232, 255, 0.62)"; context.lineWidth = 2 * dpr; context.beginPath(); context.ellipse(0, 0, 54 * dpr, 54 * dpr, -angle, 0, Math.PI * 2); context.stroke(); context.fillStyle = "#88ebff"; context.beginPath(); context.arc(Math.cos(angle) * 54 * dpr, Math.sin(angle) * 54 * dpr, 6 * dpr, 0, Math.PI * 2); context.fill(); context.restore(); label.textContent = String(frame + 1).padStart(2, "0"); } resizeCanvas(); renderFrame(0); addEventListener("resize", () => { resizeCanvas(); renderFrame(playhead.progress); }); const playhead = { progress: 0 }; Motion("sequence-preview", playhead, { to: { progress: 1 }, duration: 2.8, ease: "none", onUpdate: renderFrame, repeat: { times: -1, yoyo: true, delay: 0.6 }, }).play();`} /> The preview generates its frames procedurally so it is self-contained. A production sequence uses the same progress-to-index mapping with decoded image frames. ## Fit Frames Without Stretching The basic example fills the canvas exactly. For mixed aspect ratios, use a cover-style draw helper: ```typescript function drawCover( context: CanvasRenderingContext2D, image: CanvasImageSource, width: number, height: number ) { const source = image as { width: number; height: number }; const imageWidth = source.width; const imageHeight = source.height; const scale = Math.max(width / imageWidth, height / imageHeight); const drawWidth = imageWidth * scale; const drawHeight = imageHeight * scale; context.clearRect(0, 0, width, height); context.drawImage( image, (width - drawWidth) / 2, (height - drawHeight) / 2, drawWidth, drawHeight ); } ``` Use `Math.min(...)` instead of `Math.max(...)` for contain behavior. ## Frame Ranges and Reverse Map SDK progress into a normalized sub-range before choosing the image: ```typescript const start = 0.25; const end = 0.75; function drawPartialSequence(progress: number) { const rangedProgress = start + (end - start) * progress; const index = Math.round(rangedProgress * (frames.length - 1)); drawCover(context, frames[index], canvas.width, canvas.height); } ``` For reverse playback, map `1 - progress` instead: ```typescript const reversedProgress = 1 - progress; ``` ## Image URL Pattern Build predictable filenames from the frame index: ```typescript const urls = Array.from({ length: 120 }, (_, index) => { const frame = String(index + 1).padStart(4, "0"); return `/images/product/${frame}.webp`; }); ``` The example produces `0001.webp` through `0120.webp`. Keep all frames at the same dimensions and compression settings to avoid visible jumps. ## Loading and Performance - **Draw frame 1 as soon as it loads.** Do not leave the canvas blank while the rest of the sequence downloads. - **Decode before interaction.** `HTMLImageElement.decode()` or `createImageBitmap()` moves decode work ahead of the first scrub. - **Preload near the viewport.** An `IntersectionObserver` can delay the full queue until the section approaches. - **Use responsive frame sets.** Lower-resolution images save bandwidth and canvas memory on phones. - **Cap canvas DPR.** `Math.min(devicePixelRatio, 2)` avoids oversized drawing buffers on high-density screens. - **Skip frames when necessary.** Loading every second frame often preserves the feel while halving requests. - **Prefer WebP or AVIF.** Use PNG only when lossless alpha is required; JPEG remains useful for opaque photographic frames. ## Builder-Generated Sequences The Motion.page Builder provides upload/transcode tools, prioritized loading, responsive roots, fallback modes, and its own Image Sequence player. During export, the SDK generator links that player to the timeline with a timeline-level callback equivalent to: ```typescript Motion("product-spin", [/* timeline entries */]) .onUpdate((progress) => { window._mp_sequence_product?.handleUpdate(progress); }) .onScroll({ scrub: true }); ``` That bridge is generated code, not an `imageSequence` field accepted by the core SDK. See [Image Sequence in the Builder](/docs/builder/image-sequence) for the asset pipeline and controls. ## Related - [Scroll Trigger](/docs/sdk/scroll-trigger) — map sequence progress to scrolling - [Scroll Trigger Advanced](/docs/sdk/scroll-trigger-advanced) — pin the canvas across a longer scroll range - [Timeline Control](/docs/sdk/timeline-control) — react to timeline-level progress and lifecycle events --- ## Opacity > Fade elements in and out with opacity animations. URL: https://motion.page/docs/sdk/opacity import LivePreview from "../../components/docs/blocks/LivePreview"; Opacity controls element transparency from fully invisible (`0`) to fully visible (`1`). It's the foundation of fade animations. ## Basic Fade-In The simplest fade-in pattern uses `from: { opacity: 0 }`. Since `opacity: 1` is the element's natural CSS state, the SDK auto-resolves it as the `to` value — you only need to declare where the animation starts. `} css={`.box { width: 100px; height: 100px; background: #6633EE; border-radius: 12px; }`} js={`import { Motion } from "https://cloud.motion.page/sdk/latest.js"; Motion("fade", ".box", { from: { opacity: 0 }, duration: 0.6, ease: "power2.out", repeat: { times: -1, yoyo: true, delay: 0.6 }, }).play();`} /> ## Implicit Values `opacity: 1` is the natural CSS default. The SDK reads it from the element's computed style, so you don't need to spell it out. ```typescript // ✅ Do this — SDK fills in to: { opacity: 1 } automatically Motion("fade-in", ".element", { from: { opacity: 0 }, duration: 0.6, }).play(); // ❌ Don't do this — redundant, opacity: 1 is already the natural state Motion("fade-in", ".element", { from: { opacity: 0 }, to: { opacity: 1 }, duration: 0.6, }).play(); ``` ## Partial Opacity For ghost or dim effects, animate to values like `0.3` or `0.5`. When neither endpoint is the natural `1`, you need both `from` and `to`. ```typescript Motion("ghost", ".overlay", { from: { opacity: 0 }, to: { opacity: 0.5 }, duration: 0.4, }).play(); ``` ## Combining with Other Properties Opacity pairs naturally with movement and other effects. These combos are the bread and butter of polished UI animations. ```typescript // Fade + slide up — the classic reveal Motion("reveal", ".card", { from: { opacity: 0, y: 40 }, duration: 0.6, ease: "power2.out", }).onScroll({ scrub: false, toggleActions: "play none none none" }); // Fade + scale Motion("pop", ".badge", { from: { opacity: 0, scale: 0.8 }, duration: 0.5, ease: "back.out", }).play(); // Fade + blur Motion("blur-in", ".hero", { from: { opacity: 0, filter: "blur(10px)" }, duration: 0.8, ease: "power2.out", }).play(); ``` ## Common Patterns **Fade-in on scroll** — the bread and butter of scroll-driven reveals: ```typescript Motion("scroll-reveal", ".section", { from: { opacity: 0, y: 30 }, duration: 0.6, ease: "power2.out", }).onScroll({ scrub: false, toggleActions: "play none none none" }); ``` **Fade-out on page exit:** ```typescript Motion("exit", "body", { to: { opacity: 0 }, duration: 0.4, ease: "power2.in", }).onPageExit({ skipHref: ["anchor", "mailto"] }); ``` **Staggered fade-in for lists:** ```typescript Motion("list", ".item", { from: { opacity: 0, y: 20 }, duration: 0.5, stagger: 0.1, ease: "power2.out", }).onScroll({ scrub: false, toggleActions: "play none none none" }); ``` **Crossfade — fade out old, fade in new:** ```typescript // Fade out the current element Motion("crossfade-out", ".old", { to: { opacity: 0 }, duration: 0.3, }).play(); // Fade in the new element Motion("crossfade-in", ".new", { from: { opacity: 0 }, duration: 0.3, delay: 0.3, }).play(); ``` --- ## Static Methods > Motion.set(), Motion.kill(), Motion.killAll(), Motion.reset(), Motion.refreshScrollTriggers(). URL: https://motion.page/docs/sdk/static-methods import LivePreview from "../../components/docs/blocks/LivePreview"; Static methods on the `Motion` object let you manage the global timeline registry, set properties instantly, and handle cleanup for SPAs and dynamic content. --- ## Overview | Method | Signature | Description | |--------|-----------|-------------| | `Motion.set()` | `set(target: TargetInput, vars: AnimationVars): void` | Immediately apply CSS properties with no animation | | `Motion.get()` | `get(name: string): Timeline \| undefined` | Safe timeline retrieval — `undefined` if not found | | `Motion.has()` | `has(name: string): boolean` | Check if a named timeline is registered | | `Motion.getNames()` | `getNames(): string[]` | Array of all registered timeline names | | `Motion.kill()` | `kill(name: string): void` | Kill one timeline by name, restore initial CSS | | `Motion.killAll()` | `killAll(): void` | Kill every registered timeline | | `Motion.reset()` | `reset(targets: TargetInput): void` | Kill animations, revert text splits, clear transform cache | | `Motion.refreshScrollTriggers()` | `refreshScrollTriggers(): void` | Recalculate all scroll trigger positions | | `Motion.cleanup()` | `cleanup(): void` | Detach active ScrollTriggers and safely remove their spacer/marker DOM nodes; timelines remain registered | | `Motion.context()` | `context(fn: () => void): MotionContext` | Create a scoped context for grouped teardown | | `Motion.utils` | — | Utility functions | --- ## Motion.set() **`Motion.set()`** immediately applies CSS properties to one or more elements — a zero-duration snapshot with no animation played. ```typescript import { Motion } from "@motion.page/sdk"; // Set a single element's initial state Motion.set("#hero", { opacity: 0, y: 40 }); // Set multiple elements at once Motion.set(".card", { scale: 0.95, opacity: 0 }); // Set CSS custom properties Motion.set(":root", { "--accent": "#6633EE" }); ```
Motion.set() checking registry…
set
has
getNames

Colors and corner radii are applied synchronously before the looping reveal starts.

`} css={`.static-demo { width: min(510px, 100%); display: grid; gap: 17px; } .method-status { display: flex; align-items: center; gap: 12px; } .method-status code { padding: 5px 9px; border-radius: 7px; background: rgba(102, 51, 238, 0.18); color: #c8b9ff; font-size: 12px; } .method-status span { margin-left: auto; color: #7ee2b8; font-size: 11px; } .set-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; } .set-card { min-height: 86px; display: grid; place-items: center; border: 1px solid transparent; color: white; font-size: 12px; font-weight: 700; letter-spacing: 0.04em; } .static-demo p { color: #858cae; font-size: 11px; line-height: 1.5; text-align: center; } @media (max-width: 430px) { .set-cards { gap: 6px; } .set-card { min-height: 72px; font-size: 10px; } }`} js={`import { Motion } from "https://cloud.motion.page/sdk/latest.js"; Motion.set(".set-card", { backgroundColor: "#5130c7", borderRadius: 18, borderColor: "rgba(255,255,255,0.16)", }); Motion.set(".set-card:nth-child(2)", { backgroundColor: "#7d4be8" }); Motion.set(".set-card:nth-child(3)", { backgroundColor: "#ad76ff" }); Motion("static-methods-demo", ".set-card", { from: { opacity: 0, y: 24, rotate: -4 }, duration: 0.55, stagger: 0.12, ease: "power2.out", repeat: { times: -1, yoyo: true, delay: 0.6 }, }).play(); const registered = Motion.has("static-methods-demo"); const names = Motion.getNames(); document.querySelector("#registry-status").textContent = registered ? "registered · " + names.length + " timeline" : "not registered";`} /> | Parameter | Type | Description | |-----------|------|-------------| | `target` | `TargetInput` | CSS selector, `Element`, `NodeList`, or array of elements | | `vars` | `AnimationVars` | The same property object accepted by `from` / `to` | ### Use cases **Set initial state before animating** — hide elements before a page-load animation so they don't flash: ```typescript import { Motion } from "@motion.page/sdk"; // Hide immediately, then animate in Motion.set(".hero-title, .hero-sub, .hero-cta", { opacity: 0, y: 30 }); Motion("hero", [ { target: ".hero-title", to: { opacity: 1, y: 0 }, duration: 0.7 }, { target: ".hero-sub", to: { opacity: 1, y: 0 }, duration: 0.6, position: "+=0.1" }, { target: ".hero-cta", to: { opacity: 1, y: 0 }, duration: 0.5, position: "+=0.1" }, ]).onPageLoad(); ``` **Reset after animation completes:** ```typescript Motion("slide", ".panel", { from: { x: -300 }, duration: 0.6 }) .onComplete(() => { // Snap back without a visible animation Motion.set(".panel", { x: 0 }); }) .onPageLoad(); ``` --- ## Registry Methods The timeline registry tracks every named `Motion` timeline. These helpers let you inspect it safely without risking a thrown error. | Method | Returns | Description | |--------|---------|-------------| | `Motion.get(name)` | `Timeline \| undefined` | Returns the timeline, or `undefined` if not registered | | `Motion.has(name)` | `boolean` | `true` if the timeline exists in the registry | | `Motion.getNames()` | `string[]` | All currently registered timeline names | ```typescript import { Motion } from "@motion.page/sdk"; // Safe conditional control if (Motion.has("hero")) { Motion.get("hero")?.pause(); } // Debug — log all active timelines console.log(Motion.getNames()); // ["hero", "nav-reveal", "card-hover"] ``` > These are most useful when you need to control a timeline from a different scope than where it was created. See [Timeline Control](/docs/sdk/timeline-control) for the full set of playback and state methods available on a retrieved timeline. --- ## Motion.kill() **`Motion.kill(name)`** removes a single timeline from the registry by name and, by default, restores all CSS properties the animation touched to their pre-animation values. ```typescript import { Motion } from "@motion.page/sdk"; Motion("modal-in", "#modal", { from: { opacity: 0, scale: 0.92 }, duration: 0.4, }).onPageLoad({ paused: true }); // Show modal document.querySelector("#open-modal")?.addEventListener("click", () => { Motion("modal-in").play(); }); // Dismiss and clean up document.querySelector("#close-modal")?.addEventListener("click", () => { Motion.kill("modal-in"); }); ``` After `Motion.kill("modal-in")`, `Motion.has("modal-in")` is `false` and `Motion.get("modal-in")` returns `undefined`. Calling `Motion("modal-in")` again creates and registers a fresh empty timeline; pass a target and config to rebuild it immediately. --- ## Motion.killAll() **`Motion.killAll()`** kills every timeline currently in the registry. Use it for full-page teardown — SPA route changes, modal unmounts that own several animations, or any situation where you want a clean slate. ```typescript import { Motion } from "@motion.page/sdk"; // SPA router — clean up before navigating away router.beforeEach(() => { Motion.killAll(); }); ``` ```typescript // Vue Router equivalent router.afterEach(() => { Motion.killAll(); }); ``` --- ## Motion.reset() **`Motion.reset(targets)`** is a more thorough cleanup than `kill`. In addition to stopping animations, it: - Reverts any [Text Splitter](/docs/sdk/split-text) splits on the targets (re-merges characters/words/lines) - Clears the internal transform cache for those elements - Restores initial CSS ```typescript import { Motion } from "@motion.page/sdk"; // After a page section is removed from the DOM Motion.reset(".hero-section"); // Reset all animated elements before reinitialising Motion.reset(".animate-in"); Motion("page-reveal", ".animate-in", { from: { opacity: 0, y: 20 }, duration: 0.5, stagger: 0.08, }).onPageLoad(); ``` | Parameter | Type | Description | |-----------|------|-------------| | `targets` | `TargetInput` | CSS selector, `Element`, `NodeList`, or array of elements | Use `Motion.reset()` when reinitialising animations on elements that previously had split text or complex transform sequences — it prevents stale state from carrying over. --- ## Motion.refreshScrollTriggers() **`Motion.refreshScrollTriggers()`** recalculates the start and end scroll positions for all active scroll-based animations. Call it any time the page layout changes after scroll triggers were created. Common situations that require a refresh: - Dynamic content finishes loading (images, async data, lazy components) - An accordion or disclosure opens/closes - A tab panel becomes visible - Fonts finish loading and reflow the page - CSS transitions that change element height complete ```typescript import { Motion } from "@motion.page/sdk"; // After async content loads async function loadItems() { const items = await fetchItems(); renderList(items); // Layout has changed — recalculate scroll positions Motion.refreshScrollTriggers(); } ``` ### Debounced resize handler Scroll trigger positions are also invalidated by window resizes. Wrap in a debounce to avoid excessive recalculations: ```typescript import { Motion } from "@motion.page/sdk"; let resizeTimer: ReturnType; window.addEventListener("resize", () => { clearTimeout(resizeTimer); resizeTimer = setTimeout(() => { Motion.refreshScrollTriggers(); }, 150); }); ``` > See [Scroll Trigger](/docs/sdk/scroll-trigger) for full documentation on scroll-based animation options. --- ## Motion.cleanup() **`Motion.cleanup()`** detaches active ScrollTriggers, restores pinned elements through their normal teardown path, and safely removes spacer and debug-marker nodes. The timelines remain registered and other trigger types stay active. Combine it with `Motion.killAll()` when you also want to destroy the animations. ```typescript import { Motion } from "@motion.page/sdk"; // Full scroll-animation teardown function teardown() { Motion.killAll(); Motion.cleanup(); } // E.g., on SPA route change document.addEventListener("astro:before-swap", teardown); ``` Spacer nodes are only present when `pin` is used on a scroll trigger. Markers are visible only in development. `cleanup()` is safe to call even when neither is present, but call it during teardown because active scroll timelines stop responding to scroll afterward. --- ## Motion.context() **`Motion.context(fn)`** creates a **scoped context** that tracks every timeline registered inside the callback function. It returns a `MotionContext` object you can use to revert, refresh, or extend the group later. This is the recommended pattern for SPAs and any component that creates more than one animation — instead of calling `Motion.kill()` individually for each timeline, a single `ctx.revert()` tears them all down. ### MotionContext methods | Method | Signature | Description | |--------|-----------|-------------| | `revert()` | `revert(): void` | Kill all timelines created in this context and restore CSS | | `refresh()` | `refresh(): void` | Recalculate scroll trigger positions for timelines in this context | | `add()` | `add(fn: () => void): void` | Register additional timelines into the context after creation | ### Basic usage ```typescript import { Motion } from "@motion.page/sdk"; const ctx = Motion.context(() => { Motion("nav-fade", ".nav-item", { from: { opacity: 0, x: -12 }, stagger: 0.06, duration: 0.4, }).onPageLoad(); Motion("nav-underline", ".nav-link", { to: { scaleX: 1 }, duration: 0.25, }).onHover({ onLeave: "reverse" }); }); // Tears down both timelines in one call ctx.revert(); ``` ### React — useEffect cleanup ```typescript import { useEffect } from "react"; import { Motion } from "@motion.page/sdk"; export function HeroSection() { useEffect(() => { const ctx = Motion.context(() => { Motion("hero-title", "#hero h1", { from: { opacity: 0, y: 40 }, duration: 0.7, ease: "power3.out", }).onPageLoad(); Motion("hero-body", "#hero p", { from: { opacity: 0, y: 20 }, duration: 0.6, position: "+=0.1", }).onPageLoad(); }); // ctx.revert() kills both timelines when the component unmounts return () => ctx.revert(); }, []); return (

Welcome

Subtitle copy here.

); } ``` ### Astro View Transitions ```astro ``` ### Adding timelines after creation Use `ctx.add()` to register timelines that are created outside the original callback — for example, inside a dynamic content render: ```typescript import { Motion } from "@motion.page/sdk"; const ctx = Motion.context(() => { Motion("list-header", ".list-header", { from: { opacity: 0 }, duration: 0.4, }).onPageLoad(); }); // Later — dynamic items loaded; add their animations into the same context ctx.add(() => { Motion("list-items", ".list-item", { from: { opacity: 0, y: 12 }, duration: 0.35, stagger: 0.06, }).onPageLoad(); }); // ctx.revert() now cleans up all three timelines ``` --- ## Motion.utils `Motion.utils` provides utility functions (`toArray`, `clamp`, `random`, `snap`, `interpolate`, `mapRange`, `normalize`, `wrap`). See [Motion.utils](/docs/sdk/utilities) for the full reference. --- ## Common Patterns ### Full teardown on SPA navigation ```typescript import { Motion } from "@motion.page/sdk"; // Works with any router that exposes a before-navigate hook router.beforeEach(() => { Motion.killAll(); Motion.cleanup(); // detaches ScrollTriggers and safely removes their artifacts }); ``` ### Debounced resize with scroll refresh ```typescript import { Motion } from "@motion.page/sdk"; let timer: ReturnType; window.addEventListener("resize", () => { clearTimeout(timer); timer = setTimeout(() => Motion.refreshScrollTriggers(), 150); }); ``` ### Reinitialise after dynamic content ```typescript import { Motion } from "@motion.page/sdk"; async function loadAndAnimate() { const data = await fetchProducts(); renderProductGrid(data); // Recalculate scroll positions now that new elements exist Motion.refreshScrollTriggers(); } ``` ### Generic SPA cleanup with context ```typescript import { Motion } from "@motion.page/sdk"; let pageCtx: ReturnType | null = null; function onPageEnter() { pageCtx = Motion.context(() => { Motion("page-in", "main", { from: { opacity: 0 }, duration: 0.35 }).onPageLoad(); Motion("page-items", ".item", { from: { opacity: 0, y: 16 }, stagger: 0.07, duration: 0.4, }).onPageLoad(); }); } function onPageLeave() { pageCtx?.revert(); Motion.cleanup(); pageCtx = null; } ``` --- Related: [Timeline Control](/docs/sdk/timeline-control) · [Scroll Trigger](/docs/sdk/scroll-trigger) · [Motion.utils](/docs/sdk/utilities) --- ## SVG Animations > Draw SVG strokes with DrawSVG and animate stroke and fill colors. URL: https://motion.page/docs/sdk/svg import LivePreview from "../../components/docs/blocks/LivePreview"; Animate SVG icons and illustrations by progressively revealing strokes with `drawSVG`, or by transitioning `stroke` and `fill` colors. These properties work on any SVG element with a stroke — ``, ``, ``, ``, and so on. ## DrawSVG **`drawSVG`** progressively reveals or hides an SVG stroke by controlling where the visible portion starts and ends along the path. Pass it inside `from` or `to`. ```typescript import { Motion } from "@motion.page/sdk"; // Draw a stroke in from hidden to fully visible Motion("draw-logo", "#logo path", { from: { drawSVG: "0% 0%" }, to: { drawSVG: "0% 100%" }, duration: 1.5, ease: "power2.inOut", }).onPageLoad(); ``` Because `"0% 100%"` (fully visible) is the natural drawn state, you can use `from`-only and let the SDK resolve the endpoint from the element's current state: ```typescript // Simpler — from hidden, animate to natural (fully drawn) state Motion("draw-logo", "#logo path", { from: { drawSVG: "0% 0%" }, duration: 1.5, ease: "power2.inOut", }).onPageLoad(); ``` ## DrawSVG Format The `drawSVG` value defines a **window** along the stroke — two points (start and end) expressed as percentages or pixels. | Value | Meaning | |-------|---------| | `"0% 100%"` | Full stroke visible (natural state) | | `"0% 0%"` | Fully hidden — good draw-in start | | `"100% 100%"` | Fully hidden from the other end | | `"20% 80%"` | Middle portion only — edges hidden | | `"50%"` | Shorthand for `"0% 50%"` — first half visible | | `"100px 500px"` | Pixel range along the stroke | ```typescript // Reveal from the end (wipe-out effect) Motion("wipe", ".icon path", { from: { drawSVG: "100% 100%" }, to: { drawSVG: "0% 100%" }, duration: 0.8, ease: "power2.out", }).onScroll({ toggleActions: "play none none none" }); // Reveal middle portion Motion("partial", "circle", { from: { drawSVG: "0% 0%" }, to: { drawSVG: "20% 80%" }, duration: 1, }).onPageLoad(); ``` ### Object Format Pass an object with `start` and `end` keys. Values are **percentages 0–100** (not 0–1 fractions). ```typescript // Object format — same as "20% 80%" Motion("draw-arc", "path", { from: { drawSVG: { start: 0, end: 0 } }, to: { drawSVG: { start: 20, end: 80 } }, duration: 1.2, ease: "power2.inOut", }).onPageLoad(); ``` > **Caution:** `{ start: 0.2, end: 0.8 }` is **not** `20%–80%` — these are percentages, so `20` and `80` are the correct values. Using `0.2` / `0.8` would give you 0.2%–0.8%, a nearly invisible sliver. `} css={`.scene { display:flex; align-items:center; justify-content:center; height:100%; } .track { fill:none; stroke:#222; stroke-width:8; } .stroke { fill:none; stroke:#6633EE; stroke-width:8; stroke-linecap:round; transform-origin:center; transform:rotate(-90deg); }`} js={`import { Motion } from "https://cloud.motion.page/sdk/latest.js"; Motion("draw-circle", ".stroke", { from: { drawSVG: "0% 0%" }, to: { drawSVG: "0% 100%" }, duration: 1.4, ease: "power2.inOut", repeat: { times: -1, yoyo: true, delay: 0.6 }, }).play();`} /> ## Stroke Color **`stroke`** sets and animates the color of the line drawn around an SVG element. Pass any CSS color string. ```typescript // Animate stroke color on hover Motion("stroke-hover", ".icon", { to: { stroke: "#6633EE" }, duration: 0.3, ease: "power2.out", }).onHover({ each: true, onLeave: "reverse" }); ``` ```typescript // Transition stroke color on page load Motion("stroke-in", "path", { from: { stroke: "#cccccc" }, to: { stroke: "#ff4444" }, duration: 0.8, }).onPageLoad(); ``` Because `stroke` reads the element's current CSS as the missing endpoint, `from`-only and `to`-only both work correctly. ## Fill Color **`fill`** animates the color painted inside an SVG shape. Same behavior as `stroke` — pass any CSS color string, and use `from` or `to` depending on whether the natural CSS is the start or end state. ```typescript // Fill a shape on scroll Motion("fill-in", ".icon-shape", { from: { fill: "transparent" }, to: { fill: "#6633EE" }, duration: 0.6, ease: "power2.out", }).onScroll({ toggleActions: "play none none none" }); ``` ```typescript // Hover color change Motion("fill-hover", ".nav-icon path", { to: { fill: "#0099ff" }, duration: 0.2, }).onHover({ each: true, onLeave: "reverse" }); ``` ## Combining DrawSVG and Color `drawSVG`, `stroke`, and `fill` all work in the same animation config. Use a multi-step timeline to draw the stroke first, then fill the shape. ```typescript // Draw the stroke, then fill — sequential with position offset Motion("draw-and-fill", "#logo", [ { target: "#logo path", from: { drawSVG: "0% 0%", stroke: "#aaaaaa" }, to: { drawSVG: "0% 100%", stroke: "#6633EE" }, duration: 1.5, ease: "power2.inOut", }, { target: "#logo path", from: { fill: "transparent" }, to: { fill: "#6633EE" }, duration: 0.6, ease: "power2.out", position: "+=0.1", }, ]).onPageLoad(); ``` Or animate them in parallel within a single config: ```typescript // Draw and color-shift simultaneously Motion("draw-color", "path", { from: { drawSVG: "0% 0%", stroke: "#cccccc" }, to: { drawSVG: "0% 100%", stroke: "#6633EE" }, duration: 1.2, ease: "power2.inOut", }).onPageLoad(); ``` ## Common Patterns ### Logo Draw-In Stagger multiple paths for a sequential logo reveal. Target all paths in the SVG and use `stagger` to offset each one. ```typescript Motion("logo-draw", "#logo path", { from: { drawSVG: "0% 0%" }, duration: 1, ease: "power2.inOut", stagger: 0.15, }).onPageLoad(); ``` ### Icon Reveal on Scroll Trigger the draw when the icon enters the viewport. Each icon animates independently with `each: true`. ```typescript Motion("icon-reveal", ".feature-icon path", { from: { drawSVG: "0% 0%", opacity: 0 }, duration: 0.8, ease: "power2.out", stagger: 0.1, }).onScroll({ each: true, toggleActions: "play none none none" }); ``` ### Progress Indicator Scrub `drawSVG` against scroll to show reading progress or step completion. ```typescript Motion("progress", "#progress-ring circle", { from: { drawSVG: "0% 0%" }, to: { drawSVG: "0% 100%" }, duration: 1, }).onScroll({ scrub: true, start: "top top", end: "bottom bottom", }); ``` ### Animated Underline on Hover Draw a stroke underline from left to right on hover, reverse on leave. ```typescript Motion("underline", ".nav-link", { from: { drawSVG: "0% 0%" }, to: { drawSVG: "0% 100%" }, duration: 0.35, ease: "power2.out", }).onHover({ each: true, onLeave: "reverse" }); ``` ### Draw with Yoyo Loop Loop a stroke drawing and erasing for a continuous loading or attention indicator. ```typescript Motion("loading-ring", ".spinner circle", { from: { drawSVG: "0% 0%" }, to: { drawSVG: "0% 100%" }, duration: 1, ease: "power1.inOut", repeat: { times: -1, yoyo: true }, }).onPageLoad(); ``` ## Tips - **SVG elements must have a `stroke` CSS property set** (either inline or via CSS) for `drawSVG` to work — it manipulates `stroke-dasharray` and `stroke-dashoffset` under the hood. - **`fill: "none"`** on a path means the interior is transparent. Animate from `"none"` to a color by setting `from: { fill: "none" }` explicitly. - **`stroke-linecap: round`** on the SVG element produces smoother draw-in effects at the start and end of strokes. - Use `transformOrigin` to control the center of rotation if you also rotate SVG elements during the draw animation. --- ## Custom Cursor > Create custom cursor effects with presets, multiple instances, and state-based styling. URL: https://motion.page/docs/sdk/custom-cursor import CodeBlock from "../../components/docs/blocks/CodeBlock.astro"; import LivePreview from "../../components/docs/blocks/LivePreview"; Chain `.onCursor()` to any timeline to replace the native OS cursor with a fully animated custom cursor element. The cursor follows mouse movement, transitions between states on hover and click, and supports multiple stacked instances. ## Basic Usage ```typescript import { Motion } from "@motion.page/sdk"; Motion("cursor", "body", { duration: 0 }).onCursor({ smooth: 0.1, hideNative: true, default: { width: 12, height: 12, borderRadius: "50%", backgroundColor: "#fff", }, hover: { targets: ["a", "button", "[data-hover]"], width: 40, height: 40, backgroundColor: "transparent", border: "2px solid white", duration: 0.2, }, click: { scale: 0.8, duration: 0.1 }, }); ```
Interactive preview Move, hover, and click

The cursor eases toward the pointer, expands over the target, and compresses on click.

Click anywhere in the preview `} css={`.cursor-stage { width: min(620px, 100%); min-height: 220px; position: relative; display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 24px; padding: 28px; overflow: hidden; border: 1px solid rgba(153, 102, 255, 0.3); border-radius: 22px; background: radial-gradient(circle at 80% 20%, rgba(102, 51, 238, 0.3), transparent 38%), #0f1328; } .stage-copy { max-width: 330px; } .stage-kicker { color: #a98dff; font-size: 10px; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; } .stage-copy strong { display: block; margin-top: 8px; color: white; font-size: 25px; } .stage-copy p { margin-top: 9px; color: #9da3bf; font-size: 12px; line-height: 1.55; } .cursor-target { width: 112px; height: 112px; border: 1px solid rgba(255, 255, 255, 0.22); border-radius: 50%; background: rgba(255, 255, 255, 0.06); color: #f4f1ff; font: 600 12px/1.2 inherit; } .click-note { position: absolute; left: 28px; bottom: 18px; color: #666e91; font-size: 10px; } @media (max-width: 520px) { .cursor-stage { grid-template-columns: 1fr 80px; gap: 14px; padding: 22px; } .cursor-target { width: 80px; height: 80px; } .click-note { left: 22px; bottom: 13px; } }`} js={`import { Motion } from "https://cloud.motion.page/sdk/latest.js"; Motion("cursor-preview", "body", { duration: 0 }).onCursor({ smooth: 0.3, squeeze: true, hideNative: true, default: { width: 14, height: 14, borderRadius: "50%", backgroundColor: "#ffffff", boxShadow: "0 0 20px rgba(153, 102, 255, 0.65)", zIndex: 999999, }, hover: { targets: [".cursor-target"], width: 68, height: 68, backgroundColor: "rgba(102, 51, 238, 0.2)", border: "2px solid #b69cff", duration: 0.25, }, click: { scale: 0.65, duration: 0.1 }, });`} /> The timeline target (`"body"`) determines which element hides the native cursor when `hideNative: true`. The animation config (`{ duration: 0 }`) is a required placeholder — the cursor appearance is driven entirely by the `.onCursor()` config. --- ## Options | Option | Type | Default | Description | |--------|------|---------|-------------| | `type` | `'basic' \| 'text' \| 'media'` | `'basic'` | Cursor variant. `'text'` renders a label from an HTML attribute; `'media'` renders an image or video | | `smooth` | `number` | — | Follow lag. Lower = more delayed (fluid); higher = more responsive. Range `0–1` | | `squeeze` | `boolean \| CursorSqueezeConfig` | `false` | Scale the cursor down based on follow distance/velocity | | `hideNative` | `boolean` | `false` | Hide the OS cursor on the timeline's target element | | `default` | `CursorStateVars` | — | **Required.** CSS properties for the cursor's idle state | | `hover` | `CursorStateVars` | — | CSS properties applied when the cursor enters a hover target | | `click` | `CursorStateVars` | — | CSS properties applied while the mouse button is pressed | | `text` | `Record` | — | CSS for the inner text node. Only used with `type: 'text'` | | `media` | `Record` | — | CSS for the inner ``/`