Performance & Best Practices
Optimize animation performance — will-change, hardware acceleration, batch operations.
Smooth animations depend on keeping the browser out of its most expensive work. This guide covers what to animate, what to avoid, and how to get the most out of the SDK at scale.
GPU-accelerated properties
The browser has two paths for rendering changes:
- Composited path — the GPU handles it. No layout recalculation, no repaint. Just frame-perfect compositing at 60+ fps.
- Layout path — the CPU recalculates the entire document tree, then repaints, then composites. This is expensive and causes jank.
transform and opacity are composited. Animating x, y, scale, rotate, opacity triggers zero layout work. The GPU promotes the element to its own layer and composites it each frame.
Layout properties force a reflow. Animating width, height, top, left, margin, or padding invalidates the document layout. The browser recalculates positions for the element and every element affected by it — every frame.
// ❌ Triggers layout recalculation every frame
import { Motion } from "@motion.page/sdk";
Motion("bad-move", ".box", {
from: { left: "0px" },
to: { left: "200px" },
duration: 0.6,
}).onPageLoad();
// ✅ Composited — GPU handles it, zero layout cost
Motion("good-move", ".box", {
from: { x: 0 },
to: { x: 200 },
duration: 0.6,
}).onPageLoad(); The same principle applies to size changes. Use scale instead of width/height when you want an element to visually grow or shrink:
// ❌ Triggers layout
Motion("bad-grow", ".card", {
from: { width: "100px", height: "100px" },
to: { width: "200px", height: "200px" },
duration: 0.5,
}).onHover({ each: true, onLeave: "reverse" });
// ✅ Composited — visually identical for most hover effects
Motion("good-grow", ".card", {
to: { scale: 1.08 },
duration: 0.3,
ease: "power2.out",
}).onHover({ each: true, onLeave: "reverse" }); When layout properties are unavoidable — if you must animate
widthorheightfor structural reasons (e.g., accordion expand), use thefitanimation to FLIP between two states instead. See Fit Animation.
will-change
The will-change CSS property tells the browser to prepare for an upcoming animation by promoting the element to a GPU layer ahead of time.
/* Hint that this element will animate */
.sidebar {
will-change: transform;
} Use it sparingly. Promoting too many elements consumes GPU memory and can actually make performance worse. Good candidates are elements that animate on a known trigger — a modal that slides in, a sidebar that opens.
Do not add it globally or unconditionally. Setting will-change: transform on every .card or every .section creates hundreds of GPU layers simultaneously, eating memory with no benefit for elements that haven’t started animating yet.
/* ❌ Too broad — promotes every card at page load */
.card {
will-change: transform;
}
/* ✅ Add it just before animation, remove it after */
.card:hover {
will-change: transform;
} The SDK does not use will-change internally. When animating transforms, the SDK composes all transform properties into a single transform string per frame via an internal cache — which is enough for the browser to composite them on the GPU. will-change: transform is intentionally avoided because it pre-rasterizes the element as a bitmap at its current size. If you then scale the element up (e.g., a text block growing from scale(0.1) to scale(1)), the browser stretches a low-resolution bitmap rather than re-rendering the vector content — producing blurry text.
Bottom line: let the SDK handle GPU promotion. Only add
will-changeto your CSS when you have a measured, specific reason for it.
Batched rendering
The SDK collects all property changes made during an animation frame and flushes them together in a single batch at the end of the frame. This means animating 10 properties on 50 elements doesn’t cause 500 DOM writes — it causes 50 (one per element), with each element’s full transform composed into a single string before writing.
This happens automatically. You don’t need to configure it.
What you do need to watch: layout thrashing in callbacks. If you read a layout property (like getBoundingClientRect() or offsetHeight) and then write to the DOM inside an onUpdate callback, you force the browser to recalculate layout mid-frame.
// ❌ Reads layout, then writes — forces synchronous layout every frame
Motion("progress-bad", ".progress", {
from: { width: "0%" },
to: { width: "100%" },
duration: 1,
onUpdate: (progress) => {
const height = document.querySelector(".container").offsetHeight; // READ — forces layout
document.querySelector(".label").style.top = height * progress + "px"; // WRITE
},
}).onScroll({ scrub: true, start: "top top", end: "bottom bottom" });
// ✅ Read layout once at setup, use the cached value in the callback
const container = document.querySelector(".container");
const containerHeight = container.offsetHeight; // READ once
Motion("progress-good", ".progress", {
from: { width: "0%" },
to: { width: "100%" },
duration: 1,
onUpdate: (progress) => {
document.querySelector(".label").style.top = containerHeight * progress + "px"; // WRITE only
},
}).onScroll({ scrub: true, start: "top top", end: "bottom bottom" }); The rule: read before you animate, write inside callbacks. Cache any layout measurements at setup time so your callbacks only write.
ScrollTrigger performance
Smooth scrub reduces per-frame cost
scrub: true links animation progress to scroll position directly — the animation updates on every scroll event. For complex timelines with many animated elements, this can be expensive.
Pass a number to scrub to add lag (in seconds). The animation catches up to the scroll position gradually rather than jumping to it each frame. This trades a little responsiveness for significant smoothing on heavy scenes.
// ❌ Updates instantly on every scroll event — expensive for complex animations
Motion("parallax", ".layers", {
from: { y: "-20%" },
to: { y: "20%" },
duration: 1,
}).onScroll({ scrub: true });
// ✅ Smoothing lag — animation chases scroll position at 0.5s
Motion("parallax", ".layers", {
from: { y: "-20%" },
to: { y: "20%" },
duration: 1,
}).onScroll({ scrub: 0.5 }); Play once, don’t reverse
When elements don’t need to reverse on scroll-out, use toggleActions: "play none none none". This prevents the SDK from tracking the reverse state and re-playing the timeline on scroll-up.
// ❌ Tracks enter/leave states — more work per scroll event
Motion("reveal", ".card", {
from: { opacity: 0, y: 40 },
duration: 0.6,
stagger: 0.08,
}).onScroll({ each: true });
// ✅ Plays once and stops — no reverse tracking overhead
Motion("reveal", ".card", {
from: { opacity: 0, y: 40 },
duration: 0.6,
stagger: 0.08,
}).onScroll({
each: true,
toggleActions: "play none none none",
start: "top 85%",
}); Recalculate after layout changes
Scroll trigger positions are calculated once at initialization. If your layout shifts after the page loads (fonts rendering, images loading, components mounting), those positions go stale. Call Motion.refreshScrollTriggers() after any layout-affecting operation.
// After a layout change (e.g., accordion opened, content injected)
document.querySelector(".accordion").addEventListener("click", () => {
openAccordion();
Motion.refreshScrollTriggers();
});
// Or after all page resources finish loading
window.addEventListener("load", () => {
Motion.refreshScrollTriggers();
}); Use each carefully with large element sets
each: true creates an independent scroll trigger instance per element. Animating 200 cards with each: true creates 200 scroll listeners. For large sets, consider triggering the entire group at once and using stagger for the visual cascade:
// ❌ 200 independent scroll trigger instances
Motion("cards", ".card", {
from: { opacity: 0, y: 30 },
duration: 0.5,
stagger: 0.05,
}).onScroll({
each: true,
toggleActions: "play none none none",
start: "top 85%",
});
// ✅ One scroll trigger fires for the group, stagger handles sequencing
Motion("cards", ".card", {
from: { opacity: 0, y: 30 },
duration: 0.5,
stagger: 0.05,
ease: "power2.out",
}).onScroll({
toggleActions: "play none none none",
start: "top 85%",
}); Stagger over multiple timelines
Creating a separate timeline for each element is the slowest way to animate a group. Each timeline registers its own name, tracks its own state, and participates in the RAF loop independently.
One timeline with stagger is always more efficient than N timelines without it.
// ❌ 6 timelines — 6x the state tracking, 6x the overhead
["#step-1", "#step-2", "#step-3", "#step-4", "#step-5", "#step-6"].forEach(
(selector, i) => {
Motion(`step-${i}`, selector, {
from: { opacity: 0, y: 20 },
duration: 0.5,
delay: i * 0.1,
}).onPageLoad();
}
);
// ✅ One timeline — same visual result, fraction of the overhead
Motion("steps", ".step", {
from: { opacity: 0, y: 20 },
duration: 0.5,
stagger: 0.1,
ease: "power2.out",
}).onPageLoad(); For complex multi-phase sequences where different elements have different animations, use a multi-step timeline instead of separate named timelines:
// ❌ Three timelines — disjointed sequencing, harder to coordinate
Motion("hero-bg", ".hero-bg", { from: { opacity: 0 }, duration: 0.4 }).onPageLoad();
Motion("hero-title", ".hero-title", { from: { y: 40, opacity: 0 }, duration: 0.6, delay: 0.3 }).onPageLoad();
Motion("hero-cta", ".hero-cta", { from: { scale: 0.9, opacity: 0 }, duration: 0.4, delay: 0.7 }).onPageLoad();
// ✅ One timeline — coordinated, one RAF participant
Motion("hero", [
{ target: ".hero-bg", from: { opacity: 0 }, duration: 0.4 },
{ target: ".hero-title", from: { y: 40, opacity: 0 }, duration: 0.6, position: "+=0.1" },
{ target: ".hero-cta", from: { scale: 0.9, opacity: 0 }, duration: 0.4, position: "+=0.1" },
]).onPageLoad(); Memory management
Each named timeline stays in the registry until explicitly killed. In long-running applications — especially SPAs — failing to clean up old timelines leads to memory leaks and stale scroll triggers attached to removed DOM nodes.
Kill a single timeline
// Kill a timeline when it's no longer needed — restores original CSS
Motion.kill("hero");
// Rebuild it fresh
Motion("hero", ".hero", { from: { opacity: 0 }, duration: 0.8 }).onPageLoad(); Kill all timelines
// Full teardown — use before a major navigation or reset
Motion.killAll();
Motion.cleanup(); // Removes scroll trigger spacer and marker DOM nodes SPA cleanup with Motion.context()
Motion.context() is the right tool for React, Vue, Svelte, and any framework that mounts and unmounts components. It tracks every timeline created inside the factory function and reverts them all on ctx.revert().
// React — clean up on unmount
import { useEffect } from "react";
import { Motion } from "@motion.page/sdk";
function HeroSection() {
useEffect(() => {
const ctx = Motion.context(() => {
Motion("hero-title", ".hero-title", {
from: { opacity: 0, y: 30 },
duration: 0.7,
ease: "power2.out",
}).onPageLoad();
Motion("hero-cards", ".hero-card", {
from: { opacity: 0, y: 40 },
duration: 0.5,
stagger: 0.1,
}).onScroll({ toggleActions: "play none none none", start: "top 80%" });
});
return () => ctx.revert(); // kills all timelines created above
}, []);
return <section className="hero">{/* ... */}</section>;
} // Vue — cleanup in onUnmounted
import { onMounted, onUnmounted } from "vue";
import { Motion, type MotionContext } from "@motion.page/sdk";
let ctx: MotionContext | undefined;
onMounted(() => {
ctx = Motion.context(() => {
Motion("card-hover", ".card", {
to: { y: -6, boxShadow: "0 8px 20px rgba(0,0,0,0.12)" },
duration: 0.3,
}).onHover({ each: true, onLeave: "reverse" });
});
});
onUnmounted(() => {
ctx?.revert();
}); // Astro View Transitions — revert on each page swap
import { Motion, type MotionContext } from "@motion.page/sdk";
let ctx: MotionContext | undefined;
document.addEventListener("astro:page-load", () => {
ctx?.revert();
ctx = Motion.context(() => {
Motion("page-intro", ".page-content", {
from: { opacity: 0, y: 20 },
duration: 0.5,
}).onPageLoad();
});
}); Calling
Motion(name, ...)on an existing name appends, not replaces. If a timeline with that name already exists (e.g., after a hot reload or SPA navigation without cleanup), new entries are added to the existing timeline. CallMotion.kill(name)first, or useMotion.context()which handles this automatically viactx.revert().
Quick reference
| Situation | Recommendation |
|---|---|
| Moving elements | Use x/y, not top/left |
| Resizing elements | Use scale, or fit for structural changes |
| Fading elements | opacity — always composited |
| Many elements entering on scroll | One trigger + stagger, not each: true |
| Complex entry sequence | Multi-step timeline, not multiple named timelines |
| Smooth parallax scrub | scrub: 0.3 – scrub: 1 |
| Play-once scroll reveal | toggleActions: "play none none none" |
| React/Vue unmount | Motion.context() + ctx.revert() |
| SPA navigation | Motion.killAll() + Motion.cleanup() |
| Layout changed after init | Motion.refreshScrollTriggers() |
Related: Sequencing · Stagger · Scroll Trigger · Fit Animation