Skip to main content
Technology & EngineeringAnimation Services331 lines

Motion One

Lightweight Web Animations API wrapper — animate, timeline, spring physics, scroll-linked animations, stagger, and minimal bundle size for modern browsers.

Quick Summary28 lines
Motion One is a thin, performant animation library built on top of the browser's native Web Animations API (WAAPI). Instead of reimplementing an animation engine in JavaScript, it delegates interpolation to the browser, resulting in a tiny bundle (~3.8KB gzipped) and hardware-accelerated animations by default.

## Key Points

- **Snappy UI feedback:** `spring({ stiffness: 400, damping: 30 })`
- **Bouncy entrance:** `spring({ stiffness: 200, damping: 15 })`
- **Smooth settle:** `spring({ stiffness: 100, damping: 20 })`
- **Rely on WAAPI for transforms and opacity.** These properties animate on the compositor thread, meaning zero main-thread cost. Motion One sends them to WAAPI by default.
- **Use `spring()` for interactive feedback.** Springs respond naturally to interruption — if a user hovers off mid-animation, the spring smoothly reverses without jarring cuts.
- **Stop animations on cleanup.** Always call `controls.stop()` in the useEffect return function to prevent animations from running after component unmount.
- **Prefer element refs over selectors in React.** Using `".class"` selectors works but can match elements outside your component. Use refs for isolation.
- **Use timeline `at` for orchestration.** The `at` parameter (`"<"`, `"-0.3"`, `"+0.1"`) gives precise control over overlap without manual delay calculations.
- **Set initial styles in CSS or inline styles.** If animating from `opacity: 0`, set `style={{ opacity: 0 }}` on the element to prevent flash-of-unstyled-content before JS executes.
- **Leverage the tiny bundle size.** At ~3.8KB, Motion One is ideal for landing pages and marketing sites where bundle size directly impacts Core Web Vitals.
- **Animating layout properties (width, height, top, left).** These trigger layout recalculation on every frame. Use `transform` properties (`x`, `y`, `scale`) which composite on the GPU.
- **Forgetting to stop animations.** Leaked animations continue running their requestAnimationFrame callbacks, consuming CPU even when the component is gone.

## Quick Example

```bash
npm install motion
```

```bash
npm install @motionone/dom
```
skilldb get animation-services-skills/Motion OneFull skill: 331 lines
Paste into your CLAUDE.md or agent config

Motion One

Core Philosophy

Motion One is a thin, performant animation library built on top of the browser's native Web Animations API (WAAPI). Instead of reimplementing an animation engine in JavaScript, it delegates interpolation to the browser, resulting in a tiny bundle (~3.8KB gzipped) and hardware-accelerated animations by default.

Key mental model: use the browser's animation engine, enhance it with a better API. Motion One adds springs, timelines, stagger, and scroll-linked animations on top of what WAAPI already provides natively. Animations run on the compositor thread whenever possible, keeping the main thread free.

Setup

npm install motion

Note: Motion One has been merged into the broader motion package. For standalone usage:

npm install @motionone/dom
import { animate, timeline, stagger, scroll, spring } from "motion";

Minimal Example

import { useRef, useEffect } from "react";
import { animate } from "motion";

export function FadeIn({ children }: { children: React.ReactNode }) {
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!ref.current) return;

    const controls = animate(
      ref.current,
      { opacity: [0, 1], y: [20, 0] },
      { duration: 0.5, easing: "ease-out" }
    );

    return () => controls.stop();
  }, []);

  return <div ref={ref}>{children}</div>;
}

Key Techniques

The animate Function

The core API accepts an element (or selector), keyframes, and options.

import { animate } from "motion";

// Single element
animate("#box", { x: 200, rotate: 45 }, { duration: 0.6 });

// Multiple properties with individual settings
animate(".card", {
  opacity: [0, 1],
  y: [30, 0],
  scale: [0.95, 1],
}, {
  duration: 0.5,
  easing: "ease-out",
  delay: 0.1,
});

// Animate any value (not just DOM)
animate(
  (progress: number) => {
    console.log(`Progress: ${Math.round(progress * 100)}%`);
  },
  { duration: 1 }
);

Spring Physics

Motion One supports spring easing that generates natural, physics-based motion.

import { animate, spring } from "motion";

animate(".modal", {
  opacity: [0, 1],
  scale: [0.9, 1],
}, {
  easing: spring({ stiffness: 300, damping: 20, mass: 1 }),
});

Spring configuration guide:

  • Snappy UI feedback: spring({ stiffness: 400, damping: 30 })
  • Bouncy entrance: spring({ stiffness: 200, damping: 15 })
  • Smooth settle: spring({ stiffness: 100, damping: 20 })

Stagger

import { animate, stagger } from "motion";

// Stagger a list of elements
animate(
  ".list-item",
  { opacity: [0, 1], x: [-20, 0] },
  { delay: stagger(0.08), duration: 0.4, easing: "ease-out" }
);

// Stagger from center
animate(
  ".grid-item",
  { scale: [0, 1] },
  { delay: stagger(0.05, { from: "center" }), easing: spring() }
);

// Stagger with easing (items accelerate into the stagger)
animate(
  ".card",
  { opacity: [0, 1], y: [40, 0] },
  { delay: stagger(0.06, { easing: "ease-in" }), duration: 0.5 }
);

Timeline

Sequence multiple animations with precise timing control.

import { timeline } from "motion";

const sequence: Parameters<typeof timeline>[0] = [
  [".hero-title", { opacity: [0, 1], y: [40, 0] }, { duration: 0.6 }],
  [".hero-subtitle", { opacity: [0, 1], y: [30, 0] }, { duration: 0.5, at: "-0.3" }],
  [".hero-cta", { opacity: [0, 1], scale: [0.9, 1] }, { duration: 0.4, at: "-0.2" }],
  [".hero-image", { opacity: [0, 1], x: [60, 0] }, { duration: 0.8, at: "<" }],
];

const controls = timeline(sequence, {
  defaultOptions: { easing: "ease-out" },
});

React Integration with Timeline

import { useRef, useEffect } from "react";
import { timeline } from "motion";

export function HeroSection() {
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!containerRef.current) return;

    const controls = timeline([
      [containerRef.current.querySelector(".title")!, { opacity: [0, 1], y: [30, 0] }, { duration: 0.6 }],
      [containerRef.current.querySelector(".body")!, { opacity: [0, 1], y: [20, 0] }, { duration: 0.5, at: "-0.3" }],
      [containerRef.current.querySelector(".cta")!, { opacity: [0, 1] }, { duration: 0.4, at: "-0.2" }],
    ]);

    return () => controls.stop();
  }, []);

  return (
    <section ref={containerRef}>
      <h1 className="title" style={{ opacity: 0 }}>Welcome</h1>
      <p className="body" style={{ opacity: 0 }}>Build fast, animate faster.</p>
      <button className="cta" style={{ opacity: 0 }}>Get Started</button>
    </section>
  );
}

Scroll-Linked Animations

import { scroll, animate } from "motion";

// Progress bar tied to page scroll
scroll(animate(".progress-bar", { scaleX: [0, 1] }));

// Animate when element enters viewport
scroll(
  animate(".feature-card", { opacity: [0, 1], y: [50, 0] }),
  {
    target: document.querySelector(".feature-card")!,
    offset: ["start end", "end end"],
  }
);

React Scroll Pattern

import { useRef, useEffect } from "react";
import { scroll, animate } from "motion";

export function ScrollReveal({ children }: { children: React.ReactNode }) {
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!ref.current) return;

    const cleanup = scroll(
      animate(ref.current, { opacity: [0, 1], y: [40, 0] }, { easing: "ease-out" }),
      {
        target: ref.current,
        offset: ["start 90%", "start 50%"],
      }
    );

    return () => cleanup();
  }, []);

  return <div ref={ref} style={{ opacity: 0 }}>{children}</div>;
}

Controlling Playback

The animate and timeline functions return an AnimationControls object.

import { useRef, useState, useEffect } from "react";
import { animate, type AnimationControls } from "motion";

export function PlaybackDemo() {
  const ref = useRef<HTMLDivElement>(null);
  const controlsRef = useRef<AnimationControls | null>(null);

  useEffect(() => {
    if (!ref.current) return;

    controlsRef.current = animate(
      ref.current,
      { x: [0, 300] },
      { duration: 2, easing: "ease-in-out", direction: "alternate", repeat: Infinity }
    );

    return () => controlsRef.current?.stop();
  }, []);

  return (
    <div>
      <div ref={ref} className="w-12 h-12 bg-blue-500 rounded" />
      <div className="flex gap-2 mt-4">
        <button onClick={() => controlsRef.current?.play()}>Play</button>
        <button onClick={() => controlsRef.current?.pause()}>Pause</button>
        <button onClick={() => controlsRef.current?.reverse()}>Reverse</button>
        <button onClick={() => {
          if (controlsRef.current) controlsRef.current.currentTime = 0;
        }}>Reset</button>
      </div>
    </div>
  );
}

Custom Hooks

import { useRef, useEffect, useCallback } from "react";
import { animate, type AnimationOptions } from "motion";

export function useAnimate<T extends HTMLElement>() {
  const ref = useRef<T>(null);
  const controlsRef = useRef<ReturnType<typeof animate> | null>(null);

  const play = useCallback(
    (keyframes: Record<string, any>, options?: AnimationOptions) => {
      if (!ref.current) return;
      controlsRef.current?.stop();
      controlsRef.current = animate(ref.current, keyframes, options);
      return controlsRef.current;
    },
    []
  );

  useEffect(() => {
    return () => controlsRef.current?.stop();
  }, []);

  return { ref, play };
}

// Usage
export function InteractiveCard() {
  const { ref, play } = useAnimate<HTMLDivElement>();

  return (
    <div
      ref={ref}
      onMouseEnter={() => play({ scale: 1.05, y: -4 }, { duration: 0.2 })}
      onMouseLeave={() => play({ scale: 1, y: 0 }, { duration: 0.2 })}
      className="p-6 bg-white rounded-xl shadow"
    >
      Hover me
    </div>
  );
}

Best Practices

  • Rely on WAAPI for transforms and opacity. These properties animate on the compositor thread, meaning zero main-thread cost. Motion One sends them to WAAPI by default.
  • Use spring() for interactive feedback. Springs respond naturally to interruption — if a user hovers off mid-animation, the spring smoothly reverses without jarring cuts.
  • Stop animations on cleanup. Always call controls.stop() in the useEffect return function to prevent animations from running after component unmount.
  • Prefer element refs over selectors in React. Using ".class" selectors works but can match elements outside your component. Use refs for isolation.
  • Use timeline at for orchestration. The at parameter ("<", "-0.3", "+0.1") gives precise control over overlap without manual delay calculations.
  • Set initial styles in CSS or inline styles. If animating from opacity: 0, set style={{ opacity: 0 }} on the element to prevent flash-of-unstyled-content before JS executes.
  • Leverage the tiny bundle size. At ~3.8KB, Motion One is ideal for landing pages and marketing sites where bundle size directly impacts Core Web Vitals.

Anti-Patterns

  • Animating layout properties (width, height, top, left). These trigger layout recalculation on every frame. Use transform properties (x, y, scale) which composite on the GPU.
  • Forgetting to stop animations. Leaked animations continue running their requestAnimationFrame callbacks, consuming CPU even when the component is gone.
  • Using Motion One for complex choreography that needs scrubbing. For multi-minute, scrubbable timelines with pinning, GSAP's ScrollTrigger is more battle-tested. Motion One excels at discrete, composable micro-animations.
  • Over-using selectors instead of direct element references. animate(".card", ...) queries the entire document. In component architectures, scope to refs or use container.querySelectorAll.
  • Chaining animate calls without timeline. Calling animate twice on the same element overwrites the first animation. Use timeline to sequence them or use the finished promise to chain.
  • Ignoring the finished promise. Every animation returns a finished promise. Use it to trigger follow-up logic instead of guessing durations with setTimeout.

Install this skill directly: skilldb add animation-services-skills

Get CLI access →