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.

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 optionTypeDescription
startColorstringCSS colour for the start line label
endColorstringCSS colour for the end line label
fontSizestringLabel font size (e.g. "12px")
indentnumberHorizontal 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 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<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.

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 <body>, 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 <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:

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

SymptomLikely causeFix
Animation never playsSelector not found, trigger condition not metCheck Motion.getNames(), add onStart log, verify selector in DevTools
Elements flash before animatingInitial state not applied before first paintUse Motion.set() or set initial state in CSS
Scroll trigger fires at wrong positionLayout changed after initialisationCall Motion.refreshScrollTriggers() after layout changes
Two timelines stepping on each otherSame name used twice — second appends to firstCall Motion("name").kill() before re-registering
Timeline not found on retrievalTimeline was killed or never createdGuard with Motion.has("name") or use Motion.get("name")
Animation plays but wrong element movesSelector matches more than expectedInspect 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 · Timeline Control · Static Methods