Что представляет из себя данная статья?

Когда я начал разбираться с Motion для React, то оказалось, что свежих обзорных статей почти нет — нашёл только несколько старых постов про framer-motion. Поэтому я решил написать свой обзор: перевёл и разобрал документацию (ссылки в конце), попробовал библиотеку в деле и собрал всё в одном месте. В статье есть примеры кода, GIF-анимации и описание хуков, которых, по моему личному мнению, достаточно, чтобы понять Motion, и, возможно, попробовать его руками, сэкономив время на чтении документации.

Тестовый проект: анимированный TODO-лист

Покажу Motion в деле на примере интерактивного TODO-листа. К стандартным функциям добавлена анимация появления, исчезновения и перетаскивания задач.

Для начала создадим базовый проект (на примере, Vite):

npm create vite@latest my-motion-app -- --template react
cd my-motion-app
npm install
npm install motion

// src/main.jsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { MotionConfig } from 'motion/react';
import './index.css';
import App from './App.jsx';
createRoot(document.getElementById('root')).render(
    <StrictMode>
        <MotionConfig
            reducedMotion="user"
            layout
            transition={{ type: 'tween', ease: 'easeInOut', duration: 0.25 }}
        >
            <App />
        </MotionConfig>
    </StrictMode>,
);

Сам проект с интерактивным демо доступен по ссылке:

Вот код нашего анимированного TODO-листа находится...

...под спойлером
// src/components/TodoList.jsx
import { useState, useRef, useEffect } from 'react';
import { Reorder, AnimatePresence, motion, useScroll, useTransform } from 'motion/react';
import styles from './styles.module.css';
const initialTodos = [
    { id: 1, text: 'Почитать доку Motion' },
    { id: 2, text: 'Добавить анимацию в TODO' },
];
function TodoList() {
    const [todos, setTodos] = useState(initialTodos);
    const [newTodoText, setNewTodoText] = useState('');
    const containerRef = useRef(null);
    const [isScrolled, setIsScrolled] = useState(false);
    const { scrollYProgress } = useScroll({ container: containerRef });
    const scaleX = useTransform(scrollYProgress, [0, 1], [0, 1]);
    useEffect(() => {
        const el = containerRef.current;
        if (!el) return;
        const onScroll = () => setIsScrolled(el.scrollTop > 0);
        el.addEventListener('scroll', onScroll, { passive: true });
        return () => el.removeEventListener('scroll', onScroll);
    }, []);
    const addTodo = () => {
        if (newTodoText.trim() === '') return;
        const newId = Math.max(0, ...todos.map((t) => parseInt(t.id))) + 1;
        setTodos([{ id: newId, text: newTodoText }, ...todos]);
        setNewTodoText('');
    };
    const removeTodo = (idToRemove) => {
        setTodos(todos.filter((todo) => todo.id !== idToRemove));
    };
    const onKeyDown = (e) => {
        if (e.key === 'Enter') addTodo();
    };
    const canAdd = newTodoText.trim() !== '';
    return (
        <motion.div className={styles.container}>
            <motion.div className={styles.inputArea}>
                <motion.input
                    type="text"
                    value={newTodoText}
                    onChange={(e) => setNewTodoText(e.target.value)}
                    onKeyDown={onKeyDown}
                    placeholder="Что еще нужно сделать?"
                    className={styles.input}
                    whileFocus={{ boxShadow: '0 0 0 2px #007bff' }}
                    whileHover={{ backgroundColor: '#f0f0f0' }}
                />
                <motion.button
                    onClick={addTodo}
                    className={styles.addButton}
                    whileTap={{ scale: 0.95 }}
                    whileHover={{ scale: 1.05, backgroundColor: '#0056b3' }}
                    disabled={!canAdd}
                >
                    Добавить
                </motion.button>
            </motion.div>
            <motion.div ref={containerRef} className={styles.listScrollArea}>
                <AnimatePresence>
                    {isScrolled ? (
                        <motion.div
                            key="scroll-indicator"
                            initial={{ opacity: 0 }}
                            animate={{ opacity: 1 }}
                            exit={{ opacity: 0 }}
                            className={styles.scrollIndicator}
                            style={{ scaleX, transformOrigin: '0% 0%' }}
                        />
                    ) : null}
                </AnimatePresence>
                <Reorder.Group
                    axis="y"
                    values={todos}
                    onReorder={setTodos}
                    layoutScroll
                    className={styles.reorderGroup}
                >
                    {todos.length === 0 ? (
                        <motion.div
                            className={styles.emptyState}
                            initial={{ opacity: 0, x: -200 }}
                            animate={{ opacity: 1, x: 0 }}
                        >
                            Список пуст
                        </motion.div>
                    ) : (
                        <AnimatePresence mode="popLayout">
                            {todos.map((item) => (
                                <Reorder.Item
                                    key={item.id}
                                    value={item}
                                    initial={{ opacity: 0, x: -200 }}
                                    animate={{ opacity: 1, x: 0 }}
                                    exit={{ opacity: 0, x: 200 }}
                                    transition={{ ease: 'easeOut' }}
                                    whileHover={{ backgroundColor: '#f0f0f0' }}
                                    whileTap={{ scale: 0.98 }}
                                    className={styles.item}
                                >
                                    <motion.span>{item.text}</motion.span>
                                    <motion.button
                                        onClick={() => removeTodo(item.id)}
                                        className={styles.deleteButton}
                                        whileTap={{ scale: 0.8 }}
                                        whileHover={{ scale: 1.2, color: '#ce0000' }}
                                        transition={{ duration: 0.1, ease: 'easeOut' }}
                                        aria-label="Удалить задачу"
                                    >
                                        &times;
                                    </motion.button>
                                </Reorder.Item>
                            ))}
                        </AnimatePresence>
                    )}
                </Reorder.Group>
            </motion.div>
        </motion.div>
    );
}
export default TodoList;

Давайте разбираться, что там есть и как работает.

Основные компоненты библиотеки

Когда мы говорим о Motion, то в первую очередь подразумеваем motion-компоненты. Это, по сути, обёртки над обычными HTML- или SVG-элементами. Добавьте motion. перед любым тегом, скажем <motion.div> или <motion.circle>, и он сразу же обретёт способность к анимации.

Эти motion-компоненты принимают несколько ключевых свойств, которые и определяют анимацию:

<motion.div
        initial={{ opacity: 0, x: -100 }}
        animate={{ opacity: 1, x: 0 }}
        exit={{ opacity: 0, x: 100 }}
        transition={{ duration: 0.2 }}
        style={{ scaleX, transformOrigin: '0% 0%' }}
      />

1. initial: Это свойство задаёт начальное состояние вашего элемента, откуда начнётся анимация.

 <motion.div
        initial={{ opacity: 0, x: -100 }} // Элемент сначала невидимый  и смещен влево
  // ...
      />

Если вы его не укажете, Motion попытается сам «догадаться» о стартовой точке. Иногда бывает удобно поставить initial={false}, чтобы отключить анимацию при первом рендере, если элемент уже на месте.

 <motion.div initial={false} animate={{ opacity: 0 }} />

2. animate: ключевой элемент, собственно. Здесь вы указываете, куда должен «прилететь» ваш компонент.

<motion.div
        // ...
        animate={{ opacity: 1, x: 0 }} // Становится видимым и перемещается в свою позицию
  // ...
      />

Motion сам плавно переведёт его из состояния initial (или текущего) в целевое.

3. transition: с этим свойством мы настраиваем то, как именно будет происходить анимация: её длительность (duration), плавность (easing), задержка (delay) и даже тип анимации (например, "tween" для простого линейного движения или "spring" для упругих, более реалистичных эффектов).

<motion.div
        // ...
        transition={{ duration: 0.2 }} // Анимация длится 0.2 секунды
  // ...
      />

4. style: Важный нюанс: свойство style в motion-компонентах поддерживает независимые трансформации. Это значит, что вы можете управлять transform-свойствами (типа x, y, scale, rotate) через Motion Values (о которых поговорим далее), и они будут обновляться напрямую в DOM, не вызывая лишних ререндеров React-компонента. Это даёт больше свободы для сложных «визуальных трюков» и повышает производительность.

<motion.div
        // ...
        style={{ scaleX, transformOrigin: '0% 0%' }} // scaleX управляется useTransform, анимируя ширину
  // ...
      />

Весь подход — декларативный, что убирает головную боль по управлению DOM и таймингами. Код быстрее, чище, меньше багов. 

Оптимизация загрузки с LazyMotion.

Есть классная фишка для оптимизации веса бандла — LazyMotion. «Полный» motion-компонент может весить около 36 КБ. 

Но с LazyMotion вы можете загрузить только тот минимум, который вам действительно нужен (например, domAnimation для базовых анимаций), сократив размер до ~6 КБ. Это заметно влияет на скорость загрузки страницы.

Как использовать:

import { LazyMotion, domAnimation } from "motion/react"
import * as m from "motion/react-m"
function MyAnimatedComponent() {
  return (
    <LazyMotion features={domAnimation} strict>
      <m.div
        initial={{ opacity: 0 }}
        animate={{ opacity: 1 }}
        transition={{ duration: 0.5 }}
      >
        LazyMotion
      </m.div>
    </LazyMotion>
  );
}

Кроме синхронной загрузки, LazyMotion поддерживает и асинхронную. Это особенно полезно, если вы хотите отложить подключение анимационных функций до полной инициализации страницы, сохранив интерфейс максимально лёгким на старте.

Пример асинхронной загрузки:

// features.js
import { domAnimation } from "motion/react"
export default domAnimations
// index.js
const loadFeatures = import("./features.js")
  .then(res => res.default)
function Component() {
  return (
    <LazyMotion features={loadFeatures}>
      <m.div animate={{ scale: 1.5 }} />
    </LazyMotion>
  )
}js

Важно: внутри LazyMotion нужно использовать m.div, а не motion.div. Иначе бандл подтянет весь вес сразу, и ленивой загрузки не будет. Если включить флаг strict (по умолчанию, false), библиотека автоматически выдаст ошибку при неправильном использовании, что удобно для отладки.

AnimatePresence

Компонент AnimatePresence — это настоящий спаситель, когда дело доходит до плавной анимации элементов при их удалении из DOM. По умолчанию React просто мгновенно убирает компоненты, что выглядит резко. AnimatePresence позволяет нашим motion-компонентам красиво «попрощаться» перед исчезновением.

Как это работает? AnimatePresence отслеживает, когда его непосредственные дочерние элементы готовятся покинуть React-дерево. Это происходит в нескольких распространенных сценариях:

  • Условный рендеринг: Вы показываете или скрываете компонент с помощью условия, например: {show && <Modal />}. Когда show становится false, AnimatePresence перехватывает его уход.

  • Изменение key: Если у дочернего компонента изменяется пропс key, React воспринимает его как новый элемент, а старый удаляет. Это идеальное решение для анимированных каруселей или переключающихся вкладок (табов), где один элемент сменяет другой.

  • Удаление из списка: Элементы удаляются из динамического списка motion-компонентов, как мы видим в нашем примере с TODO-листом.

Свойство exit: анимированное исчезновение компонента

Чтобы компонент красиво исчез, мы используем свойство exit. Оно работает точно так же, как свойство animate, но применяется к прощальной анимации. Вы задаете конечные значения (например, exit={{ opacity: 0, y: 100 }}), и компонент плавно анимируется к ним, прежде чем окончательно удалиться из DOM.

<AnimatePresence initial={false}>
          {hasScroll && (
            <motion.div
              initial={{ opacity: 0, x: -100 }}
              animate={{ opacity: 1, x: 0 }}
              exit={{ opacity: 0, x: 100 }}
              transition={{ duration: 0.2 }}
              style={{ scaleX, transformOrigin: '0% 0%' }}
            />
          )}
        </AnimatePresence>

Плавные изменения расположения и размера элементов: layout и LayoutGroup

Когда элементы на странице меняют свой размер или позицию — например, при добавлении/удалении из списка, сортировке или просто расширении какого-то блока — это может выглядеть резко. Motion умеет делать эти изменения супер плавными и красивыми. За это отвечают свойства layout и компонент LayoutGroup.

layout

Если вы добавите свойство layout к любому motion-компоненту, он будет автоматически и плавно анимировать свои размеры и положение при любых изменениях. Motion сам разруливает все сложности под капотом, включая "inverse transform" (коррекцию масштаба), чтобы элементы не «прыгали» и не мерцали.

<motion.div style={{ width: isExpanded ? 200 : 100 }}
      layout
      transition={{
        type: 'tween',
        transition: {
        duration: 0.5,
        ease: 'easeInOut'
        }
      }}
    />

LayoutGroup

Что делать, если элементы, которые должны «знать» о движениях друг друга, не являются прямыми соседями в DOM? Тут на помощь приходит LayoutGroup. Просто оберните эти элементы в LayoutGroup, и они начнут «общаться» по поводу своих изменений положения и размеров, позволяя Motion координировать анимации даже между несвязанными компонентами.

Код:

function AccordionItem({ header, content }) {
  const [isOpen, setIsOpen] = useState(false);
  return (
    <motion.div
      layout
onClick={() => setIsOpen(!isOpen)}
    >
      <motion.h3 layout>
        {header}
	  // ...
      </motion.h3>
      {isOpen ? (<motion.div layout>{content}</motion.div>) : null}
    </motion.div>
  );
}
import { LayoutGroup } from 'motion/react';
  return (
    <LayoutGroup>
      {accordionData.map((item, index) => (
        <AccordionItem
          key={index}
          header={item.header}
          content={item.content}
        />
      ))}
    </LayoutGroup>
  );
}

Демонстрация:

LayoutGroup особенно полезен, когда:

  • У вас есть несколько независимых блоков (например, несколько аккордеонов или виджетов, раскиданных по странице), которые должны реагировать на изменения друг друга.

  • Вы используете общие анимации для разных элементов (layoutId). LayoutGroup также незаменим для правильной работы свойства layoutId.

layoutId: общие анимации элементов (Shared Layout Animations)

layoutId нужен для создания крутых общих анимаций между разными элементами. Это когда один элемент плавно «перетекает» в другой, меняя при этом размер и положение. Представьте:

  • Подчеркивание активной вкладки, которое плавно едет за курсором.

  • Карточка товара, которая при клике плавно превращается в полноэкранный вид этого товара.

  • Элемент из одной части интерфейса, который «телепортируется» в другую с красивой анимацией

Поскольку layoutId по умолчанию работает глобально (т.е. Motion считает, что любой элемент с одинаковым layoutId — это один и тот же «объект», просто сменивший позицию), то LayoutGroup позволяет сделать несколько таких независимых «перетеканий» на одной странице. Отдаём LayoutGroup свой id, и тогда все layoutId внутри этой группы будут работать только внутри неё, не конфликтуя с другими группами. Это как изолировать разные анимационные сценарии, чтобы они не мешали друг другу.

// ...
function Tab({ label, icon, isSelected, onClick }) {
  return (
    <motion.div
      style={{
        color: isSelected ? '#2196f3' : '#666'
      }}
      onClick={onClick}
    >
      // ...
      {isSelected
        ? (<motion.div layoutId="underline" />) 
        : null}
    </motion.div>
  )
}
function TabRow({ items, selectedTab, onTabSelect }) {
  return (
    <LayoutGroup id="tabs">
      {items.map(item => (
        <Tab
          key={item.id}
          label={item.label}
          icon={item.icon}
          isSelected={selectedTab === item.id}
          onClick={() => onTabSelect(item.id)}
        />
      ))}
    </LayoutGroup>
  )
}
// ...

Motion Values

Когда мы пробуем делать анимацию в React через useState, всё быстро упирается в производительность: каждое обновление значения тянет за собой ре-рендер компонента. Для плавных 60 fps это означает десятки ненужных обновлений в секунду, что тяжело для приложения.

Здесь и появляется Motion Value — особый объект, который хранит анимационные данные и напрямую обновляет DOM, минуя стандартный цикл React. По сути, это «живое» значение, которое меняется непрерывно и при этом не заставляет компонент лишний раз перерисовываться. Благодаря этому Motion Value становится базой для любых плавных и сложных анимаций без просадок по производительности.

Работать с Motion Value вручную можно через методы set(), get() и подписки на события on(). Но чаще всего используются хуки, которые позволяют создавать, связывать и трансформировать такие значения декларативно.

Ключевые хуки Motion Value:

Название хука

Что делает

Зачем нужен

useMotionValue

Создает базовое Motion Value.

Для значений, которые управляются вручную или при перетаскивании.

useTransform

Преобразует одно или несколько Motion Value в новое.

Для связывания данных (например, прокрутка) с визуальным свойством (например, масштаб).

useSpring

Анимирует значение с пружинной физикой.

Для создания плавных, естественных переходов и инерции.

useVelocity

Отслеживает скорость изменения значения.

Для анимаций, которые реагируют на скорость движения.

useMotionTemplate

Создает динамические CSS-строки.

Для динамического формирования сложных CSS-свойств, таких как filter.

useTime

Возвращает Motion Value, обновляющееся один раз за кадр.

Для непрерывных, зацикленных анимаций (например, вращение).

useMotionValueEvent

Слушает события Motion Value.

Выполнить что-то, когда анимация началась/закончилась.

Оставлю ссылку на демонстрацию примеров из таблицы.

Drag&Drop

Самый простой способ сделать компонент motion перетаскиваемым — добавить свойство drag. Например, drag={true} — это свободное перемещение, drag="x" ограничит движение по горизонтали, а если задатьdragConstraints, то элемент не будет вылетать за пределы.

Для создания интуитивно понятных списков с возможностью перетаскивания (привет, TODO-лист!) motion предлагает специальные компоненты Reorder.

  • Reorder.Group: оборачивает весь ваш список. Ему нужны values (массив данных) и onReorder (функция, которая обновит ваш state с новым порядком).

  • Reorder.Item: рендерится для каждого элемента списка. Ему нужны value (значение элемента) и, конечно же, уникальный key.Reorder.Item уже настроены на анимации макета, так что соседние элементы будут плавно сдвигаться, когда вы что-то перетаскиваете. Они также дружат с AnimatePresence для плавного появления/исчезновения.

Важное замечание: если ваш список находится внутри прокручиваемого контейнера, добавьте layoutScroll к Reorder.Group, чтобы всё работало как часы.

Иногда нужно начать перетаскивание с другого элемента. Используйте хук useDragControls. Создаём контроллеры, передаём их motion-компоненту и вызываем controls.start(event) с элемента-триггера. Не забудьте touch-action: none для сенсорных устройств!

import { motion, useDragControls } from "motion/react"
function Example() {
  const controls = useDragControls()
  return (
    <motion.div drag dragControls={controls} dragListener={false}>
      <button
        onPointerDown={(e) => controls.start(e)}
      >
        Жми и тяни!
      </button>
    </motion.div>
  )
}

UseScroll и useInView — для анимаций, связанных с прокруткой

Анимации, связанные с прокруткой, — это тренд. 

Хук useScroll — главный инструмент для отслеживания позиции прокрутки. Он возвращает четыре Motion Values:

scrollX, scrollY (абсолютные позиции), scrollXProgress и scrollYProgress (нормализованные значения от 0 до 1, показывающие прогресс прокрутки).

  • Прокрутка страницы: по умолчанию отслеживает весь браузер. Идеально для глобальных индикаторов прогресса.

  • Прокрутка элемента: передайте refэлемента в container, чтобы отслеживать прокрутку внутри него (например, карусели).

  • Позиция элемента внутри контейнера: используйте target с refэлемента, чтобы отслеживать его прогресс внутри прокручиваемого контейнера (например, элементы, появляющиеся в длинном списке).

Опция offset: точное определение триггеров анимации.

Это самая мощная, но поначалу запутанная опция для useScroll. offsetпозволяет точно определить, когда начинается и заканчивается анимация прогресса прокрутки, указывая точки пересечения между target и container. Можно использовать ключевые слова ("start", "center", "end"), числа (0-1), пиксели ("100px") или проценты ("50%"). Например, "start end" означает, что анимация начинается, когда верхний край вашего элемента встречается с нижним краем области просмотра.

Таблица: Опции offset для useScroll.

Значение offset

Что это значит

["start start", "end end"]

По умолчанию. Анимация от начала элемента до его конца в контейнере.

["start end", "end start"]

Анимация от момента, когда элемент появляется снизу, до момента, когда он исчезает сверху.

["0 0.5", "1 0.5"]

Анимация от начала элемента, когда он в середине контейнера, до его конца, когда он в середине контейнера.

Демонстрация примеров из таблицы.

Хук useInView — это легкий (всего 0.6 КБ!) хук, который определяет, когда элемент входит в область просмотра или покидает её. Он возвращает true, если элемент виден, и false в противном случае.

  • Использование: Создайте ref, передайте его в useInView, а затем прикрепите ref к элементу, который хотите отслеживать.

  • Опции: root (для отслеживания внутри конкретного контейнера), margin (добавляет отступы к области обнаружения), once (сработает только один раз), amount(сколько элемента должно быть видно: "some", "all" или число от 0 до 1).

import { useRef, useEffect } from "react";
import { useInView } from "motion/react";
export function InViewBasic() {
  const ref = useRef<HTMLDivElement>(null);
  const isInView = useInView(ref, { once: true });
  useEffect(() => {
    if (isInView) {
      // запускать побочные эффекты только когда элемент в зоне видимости
    }
  }, [isInView]);
  return <div ref={ref} />;
}

useInView — это не просто детектор видимости, это оптимизированный инструмент для запуска анимаций только тогда, когда они действительно нужны, плюс экономит ресурсы.

MotionConfig и useReducedMotion — для единообразия и контроля анимации на уровне всего приложения

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

Ключевые свойства MotionConfig:

  • transition: определяет переход по умолчанию для всех дочерних компонентов, если они не указали свой собственный.

  • reducedMotion: это свойство позволяет установить политику обработки уменьшенного движения для всего сайта. По умолчанию "never". Можно установить "user" (учитывает настройки пользователя), "always" (всегда уменьшенное движение) или "never" (игнорирует). Когда уменьшенное движение активно, анимации трансформации и макета отключаются, а другие (например, opacity) продолжают работать.

MotionConfig — центральный пульт управления анимациями в большом приложении. Позволяет поддерживать единый стиль и поведение.

Хук useReducedMotion — более тонкий контроль на уровне компонента. Возвращает true, если на устройстве пользователя включена настройка «Уменьшенное движение», и активно реагирует на её изменения.

Это позволяет адаптировать UI под предпочтения пользователя. Например, заменить насыщенные анимации на более простые, отключить автовоспроизведение видео или параллакс. 

Заключение

Библиотека Motion — мощный и интуитивно понятный способ добавления сложных и производительных анимаций в React-приложения. Благодаря декларативному подходу, умному управлению DOM и обширному набору хуков и компонентов, она значительно упрощает создание интерактивного и динамичного пользовательского интерфейса. Надеюсь, эта статья поможет вам уверенно начать работу с Motion и сэкономить время на пути к созданию потрясающих анимаций.

Источники:

  1. Get started with Motion for React.

  2. Motion for React.

  3. LazyMotion | Motion for React.

  4. AnimatePresence | Motion for React.

  5. LayoutGroup | Motion for React.

  6. Motion values overview | Motion for React.

  7. useTransform | Motion for React.

  8. useSpring | Motion for React.

  9. useVelocity | Motion for React.

  10. useMotionTemplate | Motion for React.

  11. useTime | Motion for React.

  12. useMotionValueEvent | Motion for React.

  13. useDragControls | Motion for React.

  14. Reorder | Motion for React.

  15. useScroll | Motion for React.

  16. useInView | Motion for React.

  17. MotionConfig | Motion for React.

  18. useReducedMotion | Motion for React.

Комментарии (0)