Motion One: The Tiny Animation Library Punching Way Above Its Weight
Motion One is a 2.5 kB animation library built on the Web Animations API — faster than GSAP, simpler than Framer Motion, and dead-easy to drop into any project.
What Even Is Motion One?
Motion One is a JavaScript animation library written by Matt Perry — the same person behind Framer Motion — released in 2021 and sitting at roughly 2.5 kB minified and gzipped. That's not a typo. Two and a half kilobytes. For context, GSAP's core alone is around 27 kB, and Framer Motion clocks in at 45+ kB when you bundle the full package for React.
The trick is that Motion One doesn't reinvent the wheel. It wraps the browser-native Web Animations API (WAAPI) with a clean, opinionated interface instead of shipping its own animation engine. The browser does the heavy lifting — on the compositor thread, so your JS thread doesn't stutter during animations. That's a genuine performance win, not marketing.
Honestly, the first time you read the source it's almost embarrassingly short. The library earns its size budget by making smart tradeoffs: no SVG morphing, no complex timeline scrubbing, no Spring physics out of the box. What you get instead is element animation, scroll-linked animations, and staggered sequences — which covers probably 80% of what you'd actually build.
Worth noting: Motion One is framework-agnostic. Vanilla JS, React, Vue, Svelte — doesn't matter. You import animate from 'motion' and you're done. If you're building interactive UI components like the ones you'd find when you browse components, it slots in without any adapter layer.
Getting Started in About 90 Seconds
Installation is one line. No peer dependencies, no config files, no getting-started wizard.
npm install motionThen the most basic animation you'll ever write looks like this:
import { animate } from 'motion';
animate('#hero-card', { opacity: [0, 1], y: [-20, 0] }, { duration: 0.4, easing: 'ease-out' });That's it. The first argument is a CSS selector or DOM node (or an array of nodes). The second is a keyframes object where each key maps to an array going from start to end. The third is options. If you've used GSAP's gsap.to() this'll feel familiar, except you don't need a ScrollTrigger plugin just to fade something in on scroll.
One more thing — Motion One's animate() returns a promise that resolves when the animation completes. Chaining animations is just await calls, no callback hell, no timeline.add() boilerplate. For something like a loading sequence this gets very readable very fast.
async function runIntro() {
await animate('.logo', { scale: [0.8, 1], opacity: [0, 1] }, { duration: 0.3 });
await animate('.headline', { y: [16, 0], opacity: [0, 1] }, { duration: 0.4 });
animate('.subtext', { opacity: [0, 1] }, { duration: 0.25 });
}The Web Animations API Advantage (And Its Limits)
Here's the part most tutorials gloss over. When you animate transform or opacity via WAAPI, the browser can run those animations entirely on the compositor thread — separate from where your JavaScript executes. That means even if your main thread is busy parsing JSON or running a React reconcile, your animations keep playing at 60fps. GSAP can't do that because it drives animation through a requestAnimationFrame loop on the main thread.
In practice, the difference is measurable. A 2023 benchmarking study from Chrome DevRel showed compositor-thread animations completing with ~0ms jank on mid-range Android devices where rAF-based animations were regularly dropping to 40fps under load. If you're building anything on lower-powered hardware or targeting mobile-first, this isn't academic — your users will feel it.
That said, WAAPI has real constraints you should know before you commit. As of mid-2026, you can't animate CSS custom properties (CSS variables) natively through WAAPI — they don't interpolate on the compositor. You also can't scrub a WAAPI animation to an arbitrary time as smoothly as you can with GSAP's timeline. If you need timeline.seek(progress) tied to a scroll position for a complex parallax scene, you'll hit friction.
Quick aside: Motion One v10 added a scroll() utility that does handle scroll-linked animations by mapping scroll progress to animation progress. It's good. Just don't expect it to replace a full GSAP ScrollTrigger setup for cinematic scroll narratives — that's not what it's for.
The honest answer is: Motion One wins on performance-per-byte for the vast majority of UI work. Entrance animations, hover effects, staggered list reveals, subtle micro-interactions. Where GSAP still wins is complex timelines, plugin ecosystems, and SVG work. Pick accordingly.
Stagger, Scroll, and Sequence — The Bread and Butter
Staggered animations are where Motion One really shines. The stagger() helper is clean and does what you expect:
import { animate, stagger } from 'motion';
animate(
'.card',
{ opacity: [0, 1], y: [24, 0] },
{ delay: stagger(0.08), duration: 0.35, easing: [0.25, 1, 0.5, 1] }
);Every .card element animates in with an 80ms delay between each one. You can pass a start offset, an easing for the stagger timing itself, and a from option to start from the center or end of the list. That last one is underused — animating from the center outward looks great for grid reveals.
Scroll-linked animations use the scroll() function introduced in v0.5.0. It's a thin wrapper around the native ScrollTimeline where supported, with a polyfill fallback.
import { animate, scroll } from 'motion';
scroll(
animate('.progress-bar', { scaleX: [0, 1] }),
{ source: document.documentElement }
);That will animate .progress-bar's scaleX from 0 to 1 as you scroll the page from top to bottom. You can scope it to a container element instead of the document to get section-specific scroll animations — combine it with an aurora or glassmorphism background and you get some genuinely striking effects for very little code.
Look, the API surface here is small enough that you can genuinely read all the docs in under an hour. That's a feature, not a limitation. Libraries with 200-page API references have a cost — you carry that mental weight every time you use them.
Motion One vs. GSAP vs. Framer Motion — Actual Tradeoffs
Let's be direct about this comparison because you've probably already Googled it and found 12 listicles that don't commit to an opinion.
GSAP is the professional-grade option. It's been around since 2008, the plugin ecosystem (ScrollTrigger, MorphSVG, DrawSVG) is genuinely irreplaceable for certain work, and the community knowledge base is enormous. It's also 27 kB minimum with commercial licensing if you use the premium plugins. If you're building a marketing site for a Fortune 500 client with complex scroll storytelling, use GSAP. Don't be a hero.
Framer Motion is the React-native choice. Declarative, AnimatePresence for exit animations, layout animations that just work, Spring physics built in. It's heavy — that 45 kB number balloons further if you use everything — but if you're already deep in React and want component-level animation with gesture support, it earns its weight. You can check out our framer-motion-advanced article for the deep patterns.
Motion One wins when you want near-zero bundle impact, when you're working outside React, or when your animation needs are genuinely UI-level rather than cinematic. It's also the right choice if you care about compositor-thread performance on mid-range devices. The animate() + stagger() + scroll() trio handles probably 85% of what a modern UI needs.
In practice, the projects where I've reached for Motion One are: component library work (tiny per-component bundle budget), marketing pages where TTI matters more than animation complexity, and multi-framework design systems where you can't depend on React. For everything else the choice depends on your existing stack, not on which library has the better GitHub star count.
Common Gotchas and How to Avoid Them
The most common mistake people hit is animating properties that aren't compositor-friendly and then wondering why performance tanked. If you animate width, height, top, left, or margin, you're forcing layout recalculation on every frame. Stick to transform (using x, y, scale, rotate shorthand) and opacity. Everything else should raise an eyebrow.
// Bad — triggers layout on every frame
animate('.box', { width: ['100px', '200px'] });
// Good — compositor only, no layout thrash
animate('.box', { scaleX: [0.5, 1] });Second gotcha: the fill option defaults to 'none', which means when your animation ends, the element snaps back to its original styles. This surprises everyone the first time. Set fill: 'forwards' to keep the end state, or better yet, apply the final styles via CSS and use the animation purely as the transition in.
Third: animate() returns an Animation object (the native WAAPI one). That means you can call .pause(), .play(), .reverse(), and .currentTime directly on it. This is actually powerful — you can build scrubbing interactions without any extra abstraction. But it also means if you forget to store the return value you lose control of the animation entirely.
Worth noting: Motion One doesn't handle will-change for you automatically. For animations that repeat or loop, set will-change: transform on the element in CSS ahead of time. The browser needs that hint to allocate compositor resources upfront rather than on the first frame.
.animated-card {
will-change: transform, opacity;
}Putting It Together: A Real UI Pattern
Here's a pattern you'd actually ship: a notification toast that slides in from the right, stays for 3 seconds, then slides out. This kind of thing is where Motion One earns its place — 20 lines, no dependencies, runs off the compositor.
import { animate } from 'motion';
export async function showToast(el) {
// Slide in from 320px right, fade up
await animate(
el,
{ x: [320, 0], opacity: [0, 1] },
{ duration: 0.35, easing: [0.34, 1.56, 0.64, 1] } // spring-ish cubic
);
// Hold for 3s
await new Promise(r => setTimeout(r, 3000));
// Slide back out
await animate(
el,
{ x: [0, 320], opacity: [1, 0] },
{ duration: 0.25, easing: 'ease-in' }
);
el.remove();
}The easing [0.34, 1.56, 0.64, 1] is a cubic-bezier that overshoots slightly before settling — it's a quick way to get spring-like behavior without pulling in a physics engine. You can dial your exact values with the gradient generator page's coming cubic-bezier tooling, or just play with the numbers manually.
That said, if you need full Spring physics — damping, stiffness, mass — Motion One v1 added a spring() easing helper. It's less configurable than Framer Motion's spring but covers the common cases:
import { animate, spring } from 'motion';
animate('.modal', { scale: [0.9, 1], opacity: [0, 1] }, { easing: spring({ stiffness: 300, damping: 20 }) });Stack this with the box shadow generator to match your shadow to the animated state, and you have a polished modal entrance that weighs essentially nothing.
FAQ
Yes. It's been in production at companies shipping real products since 2022. The API is stable, the browser support for WAAPI is excellent across Chrome, Firefox, and Safari, and the bundle cost is negligible.
Absolutely — it's framework-agnostic. Just call animate() inside a useEffect hook or an event handler. There's no React-specific adapter needed, which is part of why it's so light.
For most UI animation work, yes. For complex scroll storytelling, SVG morphing, or heavy timeline scrubbing with the full GSAP plugin ecosystem, no — GSAP still has capabilities Motion One doesn't attempt to match.
WAAPI has been fully supported in Chrome since v84, Firefox since v75, and Safari since v14. You're looking at 97%+ global coverage. Motion One polyfills the small gaps automatically.