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:
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
| Parameter | Type | Description |
|---|---|---|
name | string | Identifier for the responsive timeline. |
variants | object | Map of tier → factory. Keys: desktop, laptop, tablet, phone. |
breakpointConfig | object | Pixel boundaries: { laptops, tablets, phones }. |
Tiers and ranges
The four tiers are strictly non-overlapping — exactly one matches any width:
| Tier | Range (with defaults) |
|---|---|
desktop | wider than laptops (> 992px) |
laptop | tablets + 1 to laptops (769–992px) |
tablet | phones + 1 to tablets (577–768px) |
phone | up 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:
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:
| Device | Threshold |
|---|---|
| Phones | max-width: 576px |
| Tablets | max-width: 768px |
| Laptops | max-width: 992px |
| Desktop | min-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
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
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
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:
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:
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:
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.
Related
- Core Concepts —
Motion.context()for lifecycle management - Breakpoints — threshold reference
- Page Load Trigger — entrance animations on load
- Scroll Trigger — viewport-based triggers