Debugging Animations
Debug with markers, console logging, timeline inspection, and common pitfalls.
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.
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:
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.
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:
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:
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.
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
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:
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.
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 below for specific scenarios.
After dynamic content loads
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
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:
import { Motion } from "@motion.page/sdk";
let resizeTimer: ReturnType<typeof setTimeout>;
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.
// ❌ 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 <body>, use defer, or wrap in a DOMContentLoaded listener:
// ✅ 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:
// ✅ 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 <section id="hero">{/* ... */}</section>;
} 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:
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:
/* Elements start invisible in CSS — no flash */
.hero-title,
.hero-sub,
.hero-cta {
opacity: 0;
transform: translateY(30px);
} // 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:
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") |
// 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 · Timeline Control · Static Methods