~/wiki / motion-i-animatsiya / framer-motion-prakticheskiy-gayd

Framer Motion за один вечер: анимируй React-компоненты как профи

Основной чат

Чат для вайбкодеров: новости, гайды, поиск исполнителей, маркетплейс и разбор реальных кейсов.

$ cd раздел/ $ join vibe dev
Framer Motion за один вечер: анимируй React-компоненты как профи - обложка

Установка в 2026: motion, не framer-motion

В середине 2025 года библиотека стала независимой и переименовалась из framer-motion в motion. Пакет framer-motion по-прежнему работает и обновляется, но новые проекты лучше стартовать с нового имени:

bash
npm install motion

Импорт в React:

javascript
// Новые проекты
import { motion, AnimatePresence } from "motion/react"

// Старые проекты — работает без изменений
import { motion, AnimatePresence } from "framer-motion"

Для Next.js App Router — обязательно "use client" в начале файла: Motion работает только на клиенте.


Базовая механика: три пропса которые решают всё

Вся библиотека строится на трёх пропсах:

javascript
<motion.div
  initial={{ opacity: 0, y: 20 }}   // начальное состояние
  animate={{ opacity: 1, y: 0 }}    // целевое состояние
  exit={{ opacity: 0, y: -20 }}     // состояние при выходе
  transition={{ duration: 0.4, ease: "easeOut" }}
/>

initial — откуда. animate — куда. exit — как уходит. Браузер сам интерполирует между ними.

Главное правило производительности: анимируй только opacity и transform (x, y, scale, rotate, skew). Никогда width, height, top, left — это reflow на каждом кадре.

javascript
// Правильно
<motion.div animate={{ x: 100, opacity: 0.5, scale: 0.9 }} />

// Медленно
<motion.div animate={{ left: 100, width: "50%" }} />

Варианты: оркестрация без хаоса

Когда анимируется один элемент — инлайн-объекты работают. Когда экран с десятью карточками каждая с тремя состояниями — инлайн становится нечитаемым. Варианты выносят это за пределы JSX:

javascript
const container = {
  hidden: { opacity: 0 },
  visible: {
    opacity: 1,
    transition: {
      staggerChildren: 0.08,    // задержка между дочерними
      delayChildren: 0.2        // задержка до первого дочернего
    }
  }
}

const item = {
  hidden: { opacity: 0, y: 16 },
  visible: {
    opacity: 1,
    y: 0,
    transition: { duration: 0.4, ease: "easeOut" }
  }
}

function CardGrid() {
  return (
    <motion.ul
      variants={container}
      initial="hidden"
      animate="visible"
    >
      {cards.map(card => (
        <motion.li key={card.id} variants={item}>
          <Card {...card} />
        </motion.li>
      ))}
    </motion.ul>
  )
}

Когда родитель переходит в состояние "visible", дочерние motion.li автоматически делают то же — но с staggerChildren: 0.08, то есть с задержкой 80мс между каждым. Это и есть оркестрация: родитель командует, дети выполняют.

Важно: объект вариантов объявляй вне компонента. Если объявить внутри — он пересоздаётся на каждый рендер и Motion запускает анимацию заново.


AnimatePresence: анимация выхода

Проблема которая заставляет использовать Motion а не чистый CSS: React удаляет компонент из DOM мгновенно. exit проп никогда не успевает сыграть без AnimatePresence:

javascript
import { AnimatePresence, motion } from "motion/react"

function Notification({ message, isVisible }) {
  return (
    <AnimatePresence>
      {isVisible && (
        <motion.div
          key="notification"
          initial={{ opacity: 0, y: -16 }}
          animate={{ opacity: 1, y: 0 }}
          exit={{ opacity: 0, y: -16 }}
          transition={{ duration: 0.25 }}
        >
          {message}
        </motion.div>
      )}
    </AnimatePresence>
  )
}

AnimatePresence отслеживает дочерние элементы. Когда isVisible становится false — он даёт компоненту сыграть exit перед удалением из DOM.

Два правила AnimatePresence:

Первое — у каждого дочернего элемента должен быть уникальный key. Без него Motion не понимает что именно ушло.

Второе — mode="wait" если нужно дождаться выхода одного элемента перед входом следующего:

javascript
<AnimatePresence mode="wait">
  {step === 1 && <motion.div key="step1" ... />}
  {step === 2 && <motion.div key="step2" ... />}
</AnimatePresence>

Layout-анимации: позиция и размер сами обновляются

Самая магическая фича. Добавь layout проп — и Motion автоматически анимирует изменение позиции и размера элемента при ре-рендере. Никакого ручного расчёта координат:

javascript
function FilteredList({ items }) {
  return (
    <ul>
      {items.map(item => (
        <motion.li
          key={item.id}
          layout                              // вот и всё
          transition={{ duration: 0.3 }}
        >
          {item.name}
        </motion.li>
      ))}
    </ul>
  )
}

При фильтрации списка элементы плавно перемещаются на новые позиции. Без layout — они прыгают мгновенно.

layoutId — для Shared Element Transitions: один элемент «перетекает» в другой даже если это разные компоненты в разных частях дерева:

javascript
// Карточка в сетке
<motion.div layoutId={`card-${id}`} onClick={() => setSelected(id)} />

// Развёрнутая карточка в модалке
<motion.div layoutId={`card-${selected}`} />

Motion сам находит элементы с одинаковым layoutId и анимирует переход между ними.


Жесты: hover, tap, drag

javascript
<motion.button
  whileHover={{ scale: 1.05, backgroundColor: "#e94560" }}
  whileTap={{ scale: 0.97 }}
  transition={{ duration: 0.15 }}
>
  Нажми меня
</motion.button>

whileHover и whileTap — декларативные жесты. Не нужны onMouseEnter/onMouseLeave с состоянием.

Drag — три строки:

javascript
<motion.div
  drag
  dragConstraints={{ left: -100, right: 100, top: -50, bottom: 50 }}
  dragElastic={0.2}
/>

dragElastic — насколько элемент выходит за dragConstraints перед возвратом. 0 — жёсткие ограничения. 1 — свободно. 0.2 — небольшой пружинящий эффект.


LazyMotion: бандл с 34кб до 6кб

motion.div приносит весь Motion в бандл — ~34кб. Для большинства проектов нормально. Для критичного первого рендера — много:

javascript
// features.js — отдельный чанк
export { domAnimation as default } from "motion/react"

// App.jsx
import { LazyMotion, m } from "motion/react"

const loadFeatures = () => import("./features").then(r => r.default)

function App() {
  return (
    <LazyMotion features={loadFeatures}>
      {/* m.div вместо motion.div — работает только внутри LazyMotion */}
      <m.div animate={{ opacity: 1 }} />
    </LazyMotion>
  )
}

LazyMotion с асинхронной загрузкой domAnimation — начальный бандл ~6кб, фичи грузятся после. domMax вместо domAnimation добавляет drag и layout — ~18кб.


useReducedMotion: уважай настройки пользователя

javascript
import { useReducedMotion } from "motion/react"

function AnimatedCard({ children }) {
  const shouldReduceMotion = useReducedMotion()

  return (
    <motion.div
      initial={{ opacity: 0, y: shouldReduceMotion ? 0 : 20 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{ duration: shouldReduceMotion ? 0 : 0.4 }}
    >
      {children}
    </motion.div>
  )
}

Или глобально через MotionConfig — один раз на весь проект:

javascript
import { MotionConfig } from "motion/react"

function App() {
  return (
    <MotionConfig reducedMotion="user">
      {/* Все motion-компоненты внутри автоматически
          уважают prefers-reduced-motion */}
      <Router />
    </MotionConfig>
  )
}

reducedMotion="user" — следует системной настройке. reducedMotion="always" — отключает везде. reducedMotion="never" — всегда включено.


Три промпта для агентов

Анимированная карточка с stagger:

markdown
Создай компонент AnimatedGrid который принимает массив items.
Используй motion из "motion/react".

При монтировании карточки появляются снизу с stagger 80мс:
- Контейнер: variants с staggerChildren: 0.08
- Каждая карточка: initial opacity 0 + y 20, animate opacity 1 + y 0
- transition: duration 0.4, ease "easeOut"

Объекты вариантов объяви вне компонента.
Добавь MotionConfig reducedMotion="user" на уровне компонента.

AnimatePresence для роутинга страниц:

markdown
Добавь анимацию переходов между страницами в Next.js App Router.

Создай компонент PageTransition который оборачивает children.
Использует AnimatePresence mode="wait".
Анимация: текущая страница fade out за 0.2s, новая fade in за 0.2s.
Не использовать framer-motion — только "motion/react".
Компонент должен быть "use client".

Shared Element Transition для карточки-модалки:

markdown
Сделай переход карточка → модалка через layoutId.

Есть сетка карточек. При клике на карточку открывается модалка
с той же карточкой развёрнутой на весь экран.
Используй motion из "motion/react" и layoutId={`card-${id}`}
на обоих элементах.
Обёртка модалки — AnimatePresence.
Закрытие по клику на оверлей.
Плавный переход позиции и размера — только через layout анимацию,
не через CSS position/transform вручную.

useScroll и useTransform: parallax и прогресс

useScroll возвращает MotionValue — реактивное значение которое обновляется при скролле без ре-рендера компонента:

javascript
import { useScroll, useTransform, motion } from "motion/react"

function ParallaxHero() {
  const { scrollY } = useScroll()

  // scrollY 0→300 → translateY 0→-100
  const y = useTransform(scrollY, [0, 300], [0, -100])
  const opacity = useTransform(scrollY, [0, 200], [1, 0])

  return (
    <motion.div style={{ y, opacity }}>
      <h1>Заголовок</h1>
    </motion.div>
  )
}

useTransform — маппинг одного диапазона в другой. Первый аргумент — MotionValue, второй — входной диапазон, третий — выходной. Можно маппировать в цвета, строки, любые CSS-значения.

Для scroll-прогресса элемента во вьюпорте — useScroll с target:

javascript
function AnimatedSection() {
  const ref = useRef(null)
  const { scrollYProgress } = useScroll({
    target: ref,
    offset: ["start end", "end start"]
  })

  const scale = useTransform(scrollYProgress, [0, 0.5], [0.8, 1])

  return (
    <motion.section ref={ref} style={{ scale }}>
      Контент
    </motion.section>
  )
}

offset: ["start end", "end start"] — отслеживать от момента когда верх элемента достигает низа вьюпорта до момента когда низ элемента уходит за верх. scrollYProgress идёт 0→1 за это время.


useAnimate: императивные анимации вне JSX

Иногда нужно запустить анимацию по событию, не по изменению состояния. useAnimate даёт scope и функцию animate для императивного управления:

javascript
import { useAnimate } from "motion/react"

function ShakeButton() {
  const [scope, animate] = useAnimate()

  async function handleWrongInput() {
    // Анимация тряски при ошибке ввода
    await animate(scope.current, { x: [-8, 8, -8, 8, 0] }, { duration: 0.4 })
  }

  return (
    <div ref={scope}>
      <input onBlur={handleWrongInput} />
    </div>
  )
}

animate возвращает Promise — можно ждать окончания через await и запускать следующее действие. Это удобно для последовательностей: сначала подсветить ошибку, потом скрыть сообщение, потом сбросить поле.

useAnimate также позволяет анимировать дочерние элементы по селектору:

javascript
await animate("li", { opacity: 0 }, { delay: stagger(0.05) })

stagger — вспомогательная функция для задержки между элементами, работает внутри useAnimate так же как staggerChildren в вариантах.


Пять ошибок которые делают все

Объявляют варианты внутри компонента. При каждом рендере создаётся новый объект, Motion думает что варианты изменились и запускает анимацию снова. Выноси наружу всегда.

Забывают key в AnimatePresence. Без уникального ключа exit-анимация не играет — React не понимает что элемент заменился.

Анимируют width и height вместо scale. Для большинства «раскрывающихся» эффектов scale быстрее и плавнее — он не вызывает reflow.

Используют motion.div везде когда нужен LazyMotion. Если проект крупный и бандл важен — один раз настроить LazyMotion с m.div и забыть.

Не оборачивают в MotionConfig reducedMotion="user". Это две строки которые делают весь проект доступным для пользователей с вестибулярными расстройствами.

$ cd ../ ← назад к Motion и анимация