Responsive Animations

Create animations that adapt to different screen sizes with Motion.responsive and breakpoints.

Motion.responsive() registers one timeline with a separate variant per device tier and swaps the active variant live as the viewport crosses a breakpoint. It’s the modern, reactive way to build responsive animations — define each tier once, and the SDK handles selecting, building, and tearing down the right variant as the screen changes.

Basic Usage

Pass a name, a map of per-tier variant factories, and your breakpoint pixel boundaries:

typescript
import { Motion } from "@motion.page/sdk";

Motion.responsive(
  "hero",
  {
    desktop: () =>
      Motion("hero", ".hero-title", {
        from: { opacity: 0, y: 60 },
        duration: 0.9,
        ease: "power2.out",
      }).onPageLoad(),

    laptop: () =>
      Motion("hero", ".hero-title", {
        from: { opacity: 0, y: 40 },
        duration: 0.8,
        ease: "power2.out",
      }).onPageLoad(),

    tablet: () =>
      Motion("hero", ".hero-title", {
        from: { opacity: 0, y: 24 },
        duration: 0.6,
        ease: "power2.out",
      }).onPageLoad(),

    phone: () =>
      Motion("hero", ".hero-title", {
        from: { opacity: 0 },
        duration: 0.4,
      }).onPageLoad(),
  },
  { laptops: 992, tablets: 768, phones: 576 }
);

Each variant is a factory function that builds and registers the timeline for its tier and returns it. When the viewport enters a tier, the SDK runs that tier’s factory; when it leaves, the SDK kills that variant’s timeline (cleaning up triggers and restoring element state) before building the next one. Only one variant is ever active.

Parameters

ParameterTypeDescription
namestringIdentifier for the responsive timeline.
variantsobjectMap of tier → factory. Keys: desktop, laptop, tablet, phone.
breakpointConfigobjectPixel boundaries: { laptops, tablets, phones }.

Tiers and ranges

The four tiers are strictly non-overlapping — exactly one matches any width:

TierRange (with defaults)
desktopwider than laptops (> 992px)
laptoptablets + 1 to laptops (769–992px)
tabletphones + 1 to tablets (577–768px)
phoneup to phones (≤ 576px)

Sparse variants cascade up

You don’t have to define every tier. Missing tiers inherit from the closest wider tier, in the order desktop → laptop → tablet → phone. Define only the tiers that actually differ:

typescript
import { Motion } from "@motion.page/sdk";

Motion.responsive(
  "cards",
  {
    // Full effect on larger screens
    desktop: () =>
      Motion("cards", ".card", {
        from: { opacity: 0, y: 40 },
        duration: 0.6,
        stagger: 0.1,
      }).onScroll({ toggleActions: "play none none none" }),

    // Only phones get a lighter version; tablet inherits desktop
    phone: () =>
      Motion("cards", ".card", {
        from: { opacity: 0 },
        duration: 0.4,
      }).onScroll({ toggleActions: "play none none none" }),
  },
  { laptops: 992, tablets: 768, phones: 576 }
);

Here laptop and tablet both fall back to the desktop variant, while phone uses its own.

Disabling a tier

To run no animation on a tier, omit it and ensure no wider tier defines one — or have its factory simply not register a timeline. A tier with nothing to inherit just leaves the element in its natural state on those screens.


Default Breakpoint Values

Motion.page uses these defaults (configurable in Builder → Settings → Breakpoints). Pass the same values to breakpointConfig so SDK code and the Builder stay in sync:

DeviceThreshold
Phonesmax-width: 576px
Tabletsmax-width: 768px
Laptopsmax-width: 992px
Desktopmin-width: 993px

See Breakpoints for the full threshold reference.


Manual approach

Motion.responsive() is the recommended path. If you need full manual control — or want to gate a single animation behind a media query without variants — you can use native window.matchMedia() directly.

Mobile-only animation

typescript
import { Motion } from "@motion.page/sdk";

if (matchMedia("screen and (max-width: 576px)").matches) {
  Motion("mobile-reveal", ".hero", {
    from: { opacity: 0, y: 30 },
    duration: 0.6,
    ease: "power2.out",
  }).onPageLoad();
}

The check runs once at script execution. If it doesn’t match, the animation is never registered — no overhead, no cleanup.

Different animations per screen size

typescript
import { Motion } from "@motion.page/sdk";

// Desktop: parallax scroll effect
if (matchMedia("screen and (min-width: 993px)").matches) {
  Motion("hero-parallax", ".hero-image", {
    from: { y: -60 },
    to: { y: 60 },
    duration: 1,
  }).onScroll({ scrub: true });
}

// Tablet: simpler fade-in on scroll
if (
  matchMedia("screen and (min-width: 577px) and (max-width: 992px)").matches
) {
  Motion("hero-fade", ".hero-image", {
    from: { opacity: 0 },
    duration: 0.8,
    ease: "power2.out",
  }).onScroll({ toggleActions: "play none none none" });
}

// Mobile: fade in on load
if (matchMedia("screen and (max-width: 576px)").matches) {
  Motion("hero-mobile", ".hero-image", {
    from: { opacity: 0 },
    duration: 0.5,
  }).onPageLoad();
}

Disable an animation on mobile

typescript
import { Motion } from "@motion.page/sdk";

// Skip on mobile — hover effects don't make sense on 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" });
}

Handling resize manually

A one-time matchMedia check doesn’t react to resize. To rebuild on a breakpoint crossing, pair Motion.context() with a matchMedia change listener:

typescript
import { Motion } from "@motion.page/sdk";

const mq = matchMedia("screen and (min-width: 993px)");

const build = () =>
  Motion.context(() => {
    if (mq.matches) {
      Motion("desktop-anim", ".card", {
        from: { opacity: 0, y: 40 },
        duration: 0.6,
        stagger: 0.1,
      }).onScroll({ toggleActions: "play none none none" });
    }
  });

let ctx = build();
mq.addEventListener("change", () => {
  ctx.revert();
  ctx = build();
});

ctx.revert() kills the context’s timelines and restores initial CSS. This is exactly the resize handling Motion.responsive() does for you automatically — prefer it unless you need bespoke control.


Reduced Motion

Respect the user’s system preference with prefers-reduced-motion, regardless of which approach you use:

typescript
import { Motion } from "@motion.page/sdk";

const prefersReduced = matchMedia("(prefers-reduced-motion: reduce)").matches;

Motion("hero", ".hero-title", {
  from: prefersReduced
    ? { opacity: 0 } // Accessibility: fade only, no movement
    : { opacity: 0, y: 60 },
  duration: prefersReduced ? 0.3 : 0.9,
  ease: "power2.out",
}).onPageLoad();

Or skip non-essential animations entirely:

typescript
if (!matchMedia("(prefers-reduced-motion: reduce)").matches) {
  Motion("decorative-parallax", ".bg-shape", {
    from: { y: -80 },
    to: { y: 80 },
    duration: 1,
  }).onScroll({ scrub: true });
}

Builder Breakpoints

When using the Motion.page Builder, responsive timelines are edited visually — pick a device, change values, and only the differences are stored per tier. The Builder generates the equivalent Motion.responsive() output automatically. Global thresholds live under Settings → Breakpoints.

See Responsive Editing for the full Builder workflow.