← React/Анимации в React: Framer Motion и CSS#296 из 383← ПредыдущийСледующий →+25 XP
Полезно по теме:Гайд: React или VueПрактика: React setТермин: React HooksТема: React: хуки и экосистема

Анимации в React: Framer Motion и CSS

CSS-переходы в React

Самый простой способ анимировать элементы — CSS transitions через динамические классы или inline-стили:

import { useState } from 'react'
import styles from './Fade.module.css'

// Fade.module.css:
// .visible { opacity: 1; transform: translateY(0); transition: all 0.3s ease; }
// .hidden  { opacity: 0; transform: translateY(10px); }

function FadeIn({ children }) {
  const [visible, setVisible] = useState(false)

  return (
    <div>
      <button onClick={() => setVisible(v => !v)}>Переключить</button>
      <div className={visible ? styles.visible : styles.hidden}>
        {children}
      </div>
    </div>
  )
}

Framer Motion

Framer Motion — самая мощная библиотека анимаций для React:

npm install framer-motion
import { motion } from 'framer-motion'

// Базовые анимации
function Basic() {
  return (
    <motion.div
      initial={{ opacity: 0, y: -20 }}   // начальное состояние
      animate={{ opacity: 1, y: 0 }}     // целевое состояние
      exit={{ opacity: 0, y: 20 }}       // при удалении
      transition={{ duration: 0.3, ease: 'easeOut' }}
    >
      Анимированный блок
    </motion.div>
  )
}

Variants — именованные состояния

Variants позволяют задать несколько состояний и переключаться между ними:

const cardVariants = {
  hidden: { opacity: 0, scale: 0.8 },
  visible: {
    opacity: 1,
    scale: 1,
    transition: { duration: 0.4, ease: 'easeOut' }
  },
  hover: { scale: 1.05 },
  tap: { scale: 0.95 },
}

function Card({ title }) {
  return (
    <motion.div
      variants={cardVariants}
      initial="hidden"
      animate="visible"
      whileHover="hover"
      whileTap="tap"
    >
      {title}
    </motion.div>
  )
}

// Stagger: анимация дочерних элементов с задержкой
const listVariants = {
  visible: {
    transition: { staggerChildren: 0.1 }  // каждый ребёнок на 0.1с позже
  }
}

const itemVariants = {
  hidden: { opacity: 0, x: -20 },
  visible: { opacity: 1, x: 0 },
}

function AnimatedList({ items }) {
  return (
    <motion.ul variants={listVariants} initial="hidden" animate="visible">
      {items.map(item => (
        <motion.li key={item.id} variants={itemVariants}>
          {item.text}
        </motion.li>
      ))}
    </motion.ul>
  )
}

AnimatePresence — анимация монтирования/размонтирования

import { AnimatePresence, motion } from 'framer-motion'

function Modal({ isOpen, onClose, children }) {
  return (
    <AnimatePresence>
      {isOpen && (
        <motion.div
          key="modal"
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          exit={{ opacity: 0 }}     // ← работает только с AnimatePresence!
        >
          <div onClick={onClose} />
          <motion.div
            initial={{ scale: 0.9, y: 20 }}
            animate={{ scale: 1, y: 0 }}
            exit={{ scale: 0.9, y: 20 }}
          >
            {children}
          </motion.div>
        </motion.div>
      )}
    </AnimatePresence>
  )
}

Gestures и useMotionValue

import { motion, useMotionValue, useTransform } from 'framer-motion'

function DraggableCard() {
  const x = useMotionValue(0)
  // Поворот пропорционален смещению по X
  const rotate = useTransform(x, [-200, 200], [-30, 30])
  const opacity = useTransform(x, [-200, 0, 200], [0.5, 1, 0.5])

  return (
    <motion.div
      drag="x"
      dragConstraints={{ left: -100, right: 100 }}
      style={{ x, rotate, opacity }}
      whileDrag={{ scale: 1.1 }}
    >
      Перетащи меня
    </motion.div>
  )
}

Layout анимации

// layoutId: анимирует переход элемента между разными позициями в DOM
function GalleryItem({ item, isExpanded, onClick }) {
  return (
    <motion.div layoutId={`item-${item.id}`} onClick={onClick}>
      <motion.img src={item.src} layoutId={`img-${item.id}`} />
      {isExpanded && (
        <motion.p initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
          {item.description}
        </motion.p>
      )}
    </motion.div>
  )
}

Функции плавности (easing)

// Встроенные easing
transition={{ ease: 'linear' }}       // равномерно
transition={{ ease: 'easeIn' }}       // ускорение в начале
transition={{ ease: 'easeOut' }}      // замедление в конце
transition={{ ease: 'easeInOut' }}    // ускорение и замедление
transition={{ ease: 'anticipate' }}   // небольшой откат назад перед движением
transition={{ ease: 'backOut' }}      // перелёт за цель с возвратом
transition={{ ease: [0.25, 0.1, 0.25, 1] }}  // кубическая кривая Безье

Примеры

Планировщик анимаций на ванильном JS: keyframes, easing-функции, последовательные и параллельные анимации

// Реализуем планировщик анимаций:
// keyframes, easing, последовательность и параллельность.

// --- Easing функции ---

const easings = {
  linear: t => t,
  easeIn: t => t * t,
  easeOut: t => t * (2 - t),
  'ease-in-out': t => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t,
  backOut: t => {
    const c1 = 1.70158
    return 1 + (c1 + 1) * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2)
  },
}

// --- Интерполяция значения ---

function lerp(from, to, t) {
  return from + (to - from) * t
}

// --- Анимация одного свойства ---

function animateValue(from, to, duration, easingName, onUpdate, onComplete) {
  const easeFn = easings[easingName] || easings.linear
  const startTime = Date.now()
  const frames = []

  function tick() {
    const elapsed = Date.now() - startTime
    const rawT = Math.min(elapsed / duration, 1)
    const t = easeFn(rawT)
    const value = lerp(from, to, t)

    frames.push({ t: rawT.toFixed(2), value: Math.round(value * 100) / 100 })
    onUpdate(value)

    if (rawT < 1) {
      setTimeout(tick, 16)  // ~60fps симуляция
    } else {
      onComplete(frames)
    }
  }

  tick()
}

// --- Планировщик ---

function createAnimationScheduler() {
  const animations = []

  return {
    // Добавить анимацию в очередь
    add(name, from, to, duration, easingName = 'linear') {
      animations.push({ name, from, to, duration, easingName })
      return this  // chaining
    },

    // Запустить ВСЕ параллельно
    runParallel() {
      console.log('=== Параллельный запуск', animations.length, 'анимаций ===')
      const promises = animations.map(anim => {
        return new Promise(resolve => {
          const samples = []
          animateValue(
            anim.from, anim.to, anim.duration, anim.easingName,
            (v) => samples.push(Math.round(v * 10) / 10),
            (frames) => {
              console.log('[' + anim.name + '] ' + anim.easingName + ': ' +
                anim.from + ' → ' + anim.to + ' за ' + anim.duration + 'мс')
              console.log('  Ключевые точки:', samples.filter((_, i) => i % 5 === 0 || i === samples.length - 1).join(', '))
              resolve({ name: anim.name, finalValue: anim.to })
            }
          )
        })
      })
      return Promise.all(promises)
    },

    // Запустить ПОСЛЕДОВАТЕЛЬНО
    async runSequential() {
      console.log('=== Последовательный запуск', animations.length, 'анимаций ===')
      const results = []
      for (const anim of animations) {
        await new Promise(resolve => {
          animateValue(
            anim.from, anim.to, anim.duration, anim.easingName,
            () => {},
            () => {
              console.log('[' + anim.name + '] завершена: ' + anim.from + ' → ' + anim.to)
              results.push({ name: anim.name, done: true })
              resolve()
            }
          )
        })
      }
      return results
    }
  }
}

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

async function demo() {
  // Параллельно
  const scheduler1 = createAnimationScheduler()
  scheduler1
    .add('opacity', 0, 1, 200, 'ease-in-out')
    .add('translateY', -20, 0, 250, 'easeOut')
    .add('scale', 0.8, 1, 300, 'backOut')

  const results = await scheduler1.runParallel()
  console.log('Все завершены:', results.map(r => r.name).join(', '))

  console.log('')

  // Последовательно
  const scheduler2 = createAnimationScheduler()
  scheduler2
    .add('fadeIn', 0, 1, 150, 'easeOut')
    .add('slideIn', -100, 0, 200, 'ease-in-out')

  await scheduler2.runSequential()
  console.log('Последовательность завершена!')
}

demo()

Анимации в React: Framer Motion и CSS

CSS-переходы в React

Самый простой способ анимировать элементы — CSS transitions через динамические классы или inline-стили:

import { useState } from 'react'
import styles from './Fade.module.css'

// Fade.module.css:
// .visible { opacity: 1; transform: translateY(0); transition: all 0.3s ease; }
// .hidden  { opacity: 0; transform: translateY(10px); }

function FadeIn({ children }) {
  const [visible, setVisible] = useState(false)

  return (
    <div>
      <button onClick={() => setVisible(v => !v)}>Переключить</button>
      <div className={visible ? styles.visible : styles.hidden}>
        {children}
      </div>
    </div>
  )
}

Framer Motion

Framer Motion — самая мощная библиотека анимаций для React:

npm install framer-motion
import { motion } from 'framer-motion'

// Базовые анимации
function Basic() {
  return (
    <motion.div
      initial={{ opacity: 0, y: -20 }}   // начальное состояние
      animate={{ opacity: 1, y: 0 }}     // целевое состояние
      exit={{ opacity: 0, y: 20 }}       // при удалении
      transition={{ duration: 0.3, ease: 'easeOut' }}
    >
      Анимированный блок
    </motion.div>
  )
}

Variants — именованные состояния

Variants позволяют задать несколько состояний и переключаться между ними:

const cardVariants = {
  hidden: { opacity: 0, scale: 0.8 },
  visible: {
    opacity: 1,
    scale: 1,
    transition: { duration: 0.4, ease: 'easeOut' }
  },
  hover: { scale: 1.05 },
  tap: { scale: 0.95 },
}

function Card({ title }) {
  return (
    <motion.div
      variants={cardVariants}
      initial="hidden"
      animate="visible"
      whileHover="hover"
      whileTap="tap"
    >
      {title}
    </motion.div>
  )
}

// Stagger: анимация дочерних элементов с задержкой
const listVariants = {
  visible: {
    transition: { staggerChildren: 0.1 }  // каждый ребёнок на 0.1с позже
  }
}

const itemVariants = {
  hidden: { opacity: 0, x: -20 },
  visible: { opacity: 1, x: 0 },
}

function AnimatedList({ items }) {
  return (
    <motion.ul variants={listVariants} initial="hidden" animate="visible">
      {items.map(item => (
        <motion.li key={item.id} variants={itemVariants}>
          {item.text}
        </motion.li>
      ))}
    </motion.ul>
  )
}

AnimatePresence — анимация монтирования/размонтирования

import { AnimatePresence, motion } from 'framer-motion'

function Modal({ isOpen, onClose, children }) {
  return (
    <AnimatePresence>
      {isOpen && (
        <motion.div
          key="modal"
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          exit={{ opacity: 0 }}     // ← работает только с AnimatePresence!
        >
          <div onClick={onClose} />
          <motion.div
            initial={{ scale: 0.9, y: 20 }}
            animate={{ scale: 1, y: 0 }}
            exit={{ scale: 0.9, y: 20 }}
          >
            {children}
          </motion.div>
        </motion.div>
      )}
    </AnimatePresence>
  )
}

Gestures и useMotionValue

import { motion, useMotionValue, useTransform } from 'framer-motion'

function DraggableCard() {
  const x = useMotionValue(0)
  // Поворот пропорционален смещению по X
  const rotate = useTransform(x, [-200, 200], [-30, 30])
  const opacity = useTransform(x, [-200, 0, 200], [0.5, 1, 0.5])

  return (
    <motion.div
      drag="x"
      dragConstraints={{ left: -100, right: 100 }}
      style={{ x, rotate, opacity }}
      whileDrag={{ scale: 1.1 }}
    >
      Перетащи меня
    </motion.div>
  )
}

Layout анимации

// layoutId: анимирует переход элемента между разными позициями в DOM
function GalleryItem({ item, isExpanded, onClick }) {
  return (
    <motion.div layoutId={`item-${item.id}`} onClick={onClick}>
      <motion.img src={item.src} layoutId={`img-${item.id}`} />
      {isExpanded && (
        <motion.p initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
          {item.description}
        </motion.p>
      )}
    </motion.div>
  )
}

Функции плавности (easing)

// Встроенные easing
transition={{ ease: 'linear' }}       // равномерно
transition={{ ease: 'easeIn' }}       // ускорение в начале
transition={{ ease: 'easeOut' }}      // замедление в конце
transition={{ ease: 'easeInOut' }}    // ускорение и замедление
transition={{ ease: 'anticipate' }}   // небольшой откат назад перед движением
transition={{ ease: 'backOut' }}      // перелёт за цель с возвратом
transition={{ ease: [0.25, 0.1, 0.25, 1] }}  // кубическая кривая Безье

Примеры

Планировщик анимаций на ванильном JS: keyframes, easing-функции, последовательные и параллельные анимации

// Реализуем планировщик анимаций:
// keyframes, easing, последовательность и параллельность.

// --- Easing функции ---

const easings = {
  linear: t => t,
  easeIn: t => t * t,
  easeOut: t => t * (2 - t),
  'ease-in-out': t => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t,
  backOut: t => {
    const c1 = 1.70158
    return 1 + (c1 + 1) * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2)
  },
}

// --- Интерполяция значения ---

function lerp(from, to, t) {
  return from + (to - from) * t
}

// --- Анимация одного свойства ---

function animateValue(from, to, duration, easingName, onUpdate, onComplete) {
  const easeFn = easings[easingName] || easings.linear
  const startTime = Date.now()
  const frames = []

  function tick() {
    const elapsed = Date.now() - startTime
    const rawT = Math.min(elapsed / duration, 1)
    const t = easeFn(rawT)
    const value = lerp(from, to, t)

    frames.push({ t: rawT.toFixed(2), value: Math.round(value * 100) / 100 })
    onUpdate(value)

    if (rawT < 1) {
      setTimeout(tick, 16)  // ~60fps симуляция
    } else {
      onComplete(frames)
    }
  }

  tick()
}

// --- Планировщик ---

function createAnimationScheduler() {
  const animations = []

  return {
    // Добавить анимацию в очередь
    add(name, from, to, duration, easingName = 'linear') {
      animations.push({ name, from, to, duration, easingName })
      return this  // chaining
    },

    // Запустить ВСЕ параллельно
    runParallel() {
      console.log('=== Параллельный запуск', animations.length, 'анимаций ===')
      const promises = animations.map(anim => {
        return new Promise(resolve => {
          const samples = []
          animateValue(
            anim.from, anim.to, anim.duration, anim.easingName,
            (v) => samples.push(Math.round(v * 10) / 10),
            (frames) => {
              console.log('[' + anim.name + '] ' + anim.easingName + ': ' +
                anim.from + ' → ' + anim.to + ' за ' + anim.duration + 'мс')
              console.log('  Ключевые точки:', samples.filter((_, i) => i % 5 === 0 || i === samples.length - 1).join(', '))
              resolve({ name: anim.name, finalValue: anim.to })
            }
          )
        })
      })
      return Promise.all(promises)
    },

    // Запустить ПОСЛЕДОВАТЕЛЬНО
    async runSequential() {
      console.log('=== Последовательный запуск', animations.length, 'анимаций ===')
      const results = []
      for (const anim of animations) {
        await new Promise(resolve => {
          animateValue(
            anim.from, anim.to, anim.duration, anim.easingName,
            () => {},
            () => {
              console.log('[' + anim.name + '] завершена: ' + anim.from + ' → ' + anim.to)
              results.push({ name: anim.name, done: true })
              resolve()
            }
          )
        })
      }
      return results
    }
  }
}

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

async function demo() {
  // Параллельно
  const scheduler1 = createAnimationScheduler()
  scheduler1
    .add('opacity', 0, 1, 200, 'ease-in-out')
    .add('translateY', -20, 0, 250, 'easeOut')
    .add('scale', 0.8, 1, 300, 'backOut')

  const results = await scheduler1.runParallel()
  console.log('Все завершены:', results.map(r => r.name).join(', '))

  console.log('')

  // Последовательно
  const scheduler2 = createAnimationScheduler()
  scheduler2
    .add('fadeIn', 0, 1, 150, 'easeOut')
    .add('slideIn', -100, 0, 200, 'ease-in-out')

  await scheduler2.runSequential()
  console.log('Последовательность завершена!')
}

demo()

Задание

Создай компонент AnimatedBox, который использует CSS keyframe анимацию через состояние. Компонент должен: отображать квадрат 100x100px с цветом фона, иметь кнопку "Анимировать", при клике на которую запускается анимация (isAnimating = true). Когда isAnimating = true, добавь класс "animate-pulse" к квадрату. Используй inline стили для анимации: при isAnimating добавь animation: "pulse 0.5s ease-in-out". Также добавь @keyframes pulse через тег <style>.

Подсказка

useState(false) для начального состояния. При клике setIsAnimating(true), через setTimeout(500мс) setIsAnimating(false). В boxStyle: animation: isAnimating ? "pulse 0.5s ease-in-out" : "none". В keyframes: scale(1.1) и opacity: 0.7 для 50% точки.

Загружаем среду выполнения...
Загружаем AI-помощника...