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

Анимации в React

CSS-анимации: самый простой способ

Для большинства анимаций не нужны библиотеки. CSS transitions работают при изменении класса:

// Стили:
// .item { opacity: 0; transform: translateY(10px); transition: all 0.3s ease; }
// .item.visible { opacity: 1; transform: translateY(0); }

function AnimatedItem({ isVisible, children }) {
  return (
    <div className={'item' + (isVisible ? ' visible' : '')}>
      {children}
    </div>
  )
}

CSS анимации входа:

@keyframes slideIn {
  from { opacity: 0; transform: translateX(-20px); }
  to   { opacity: 1; transform: translateX(0); }
}

.card-enter {
  animation: slideIn 0.3s ease forwards;
}

Проблема анимаций выхода

CSS transitions легко применить при входе, но сложно при выходе. Компонент размонтируется — CSS не успевает сработать.

Решение — задержать удаление из DOM:

function useDelayedUnmount(isVisible, delay = 300) {
  const [shouldRender, setShouldRender] = useState(isVisible)

  useEffect(() => {
    if (isVisible) {
      setShouldRender(true)
    } else {
      // Задерживаем удаление на время анимации выхода
      const timer = setTimeout(() => setShouldRender(false), delay)
      return () => clearTimeout(timer)
    }
  }, [isVisible, delay])

  return shouldRender
}

function AnimatedModal({ isOpen }) {
  const shouldRender = useDelayedUnmount(isOpen, 300)

  if (!shouldRender) return null

  return (
    <div className={'modal ' + (isOpen ? 'modal-enter' : 'modal-exit')}>
      Содержимое модала
    </div>
  )
}

Framer Motion: декларативные анимации

framer-motion — самая популярная библиотека анимаций для React:

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

// Базовая анимация:
<motion.div
  initial={{ opacity: 0, y: -20 }}  // начальное состояние
  animate={{ opacity: 1, y: 0 }}    // конечное состояние
  exit={{ opacity: 0, y: 20 }}      // анимация выхода
  transition={{ duration: 0.3 }}
>
  Привет, я анимирован!
</motion.div>

// AnimatePresence: для анимации монтирования/размонтирования
function NotificationList({ notifications }) {
  return (
    <AnimatePresence>
      {notifications.map(n => (
        <motion.div
          key={n.id}
          initial={{ opacity: 0, x: 100 }}
          animate={{ opacity: 1, x: 0 }}
          exit={{ opacity: 0, x: 100, height: 0 }}
          transition={{ type: 'spring', stiffness: 300 }}
        >
          {n.message}
        </motion.div>
      ))}
    </AnimatePresence>
  )
}

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

const cardVariants = {
  hidden: { opacity: 0, scale: 0.8 },
  visible: { opacity: 1, scale: 1, transition: { duration: 0.3 } },
  hover:   { scale: 1.05, boxShadow: '0 10px 20px rgba(0,0,0,0.2)' },
}

<motion.div
  variants={cardVariants}
  initial="hidden"
  animate="visible"
  whileHover="hover"
>
  Карточка с анимацией
</motion.div>

Когда CSS, когда библиотека

| Сценарий | CSS | Framer Motion |

|---|---|---|

| Hover эффекты | ✓ | — |

| Простые переходы (opacity, transform) | ✓ | — |

| Анимации входа/выхода | С хуком | ✓ |

| Drag & Drop | — | ✓ |

| Сложные последовательности | — | ✓ |

| Физические анимации (spring) | — | ✓ |

| Layout анимации | — | ✓ |

| Производительность | Отлично | Хорошо |

requestAnimationFrame для JavaScript-анимаций

Когда нужна анимация через JavaScript (не CSS):

function useAnimatedValue(targetValue, duration = 500) {
  const [value, setValue] = useState(0)

  useEffect(() => {
    const startValue = value
    const startTime = performance.now()

    function animate(currentTime) {
      const elapsed = currentTime - startTime
      const progress = Math.min(elapsed / duration, 1)

      // easeOutCubic
      const eased = 1 - Math.pow(1 - progress, 3)
      setValue(startValue + (targetValue - startValue) * eased)

      if (progress < 1) {
        requestAnimationFrame(animate)
      }
    }

    requestAnimationFrame(animate)
  }, [targetValue])

  return value
}

// Использование: анимированный счётчик
function Counter({ target }) {
  const animatedValue = useAnimatedValue(target, 1000)
  return <span>{Math.round(animatedValue)}</span>
}

Производительность анимаций

Анимируйте только GPU-свойства: transform и opacity. Избегайте анимации width, height, margin, top, left — они вызывают дорогостоящий reflow:

/* Плохо — вызывает reflow браузера: */
.box { transition: width 0.3s, left 0.3s; }

/* Хорошо — только GPU: */
.box { transition: transform 0.3s, opacity 0.3s; }
/* Вместо left/top используем translate: */
.box.moved { transform: translateX(100px); }

Примеры

Реализация системы анимированных входа/выхода элементов списка через CSS-классы и requestAnimationFrame, с измерением производительности

// Реализуем анимации добавления/удаления элементов без библиотек.
// Это концепция AnimatePresence из Framer Motion, реализованная вручную.

// --- Менеджер анимаций ---

class AnimationManager {
  constructor(options = {}) {
    this.duration = options.duration || 300   // мс
    this.entering = new Map()  // элементы в процессе входа
    this.exiting  = new Map()  // элементы в процессе выхода
    this.callbacks = []
  }

  // Анимация входа: opacity 0 → 1, translateY 10px → 0
  animateEnter(item) {
    return new Promise(resolve => {
      const animState = { progress: 0, item }
      this.entering.set(item.id, animState)

      const start = performance.now()
      const animate = (now) => {
        const elapsed = now - start
        const raw = Math.min(elapsed / this.duration, 1)
        // easeOutCubic: быстрый старт, мягкое завершение
        const progress = 1 - Math.pow(1 - raw, 3)

        animState.progress = progress
        animState.style = {
          opacity: progress,
          transform: 'translateY(' + (10 * (1 - progress)) + 'px)',
        }

        this.notify()

        if (raw < 1) {
          requestAnimationFrame(animate)
        } else {
          this.entering.delete(item.id)
          resolve()
        }
      }

      requestAnimationFrame(animate)
    })
  }

  // Анимация выхода: opacity 1 → 0, translateX 0 → 20px
  animateExit(item) {
    return new Promise(resolve => {
      const animState = { progress: 1, item }
      this.exiting.set(item.id, animState)

      const start = performance.now()
      const animate = (now) => {
        const elapsed = now - start
        const raw = Math.min(elapsed / this.duration, 1)
        // easeInCubic: медленный старт, быстрое завершение
        const progress = 1 - Math.pow(raw, 3)

        animState.progress = progress
        animState.style = {
          opacity: progress,
          transform: 'translateX(' + (20 * (1 - progress)) + 'px)',
        }

        this.notify()

        if (raw < 1) {
          requestAnimationFrame(animate)
        } else {
          this.exiting.delete(item.id)
          resolve()
        }
      }

      requestAnimationFrame(animate)
    })
  }

  notify() {
    this.callbacks.forEach(fn => fn())
  }

  subscribe(fn) {
    this.callbacks.push(fn)
  }
}

// --- AnimatedList ---

class AnimatedList {
  constructor() {
    this.items = []
    this.animMgr = new AnimationManager({ duration: 200 })  // быстрее для теста
    this.nextId = 1
  }

  async add(text) {
    const item = { id: this.nextId++, text }
    this.items.push(item)
    console.log('
+ Добавляем:', item.text)
    await this.animMgr.animateEnter(item)
    console.log('✓ Анимация входа завершена для:', item.text)
    return item
  }

  async remove(id) {
    const item = this.items.find(i => i.id === id)
    if (!item) return

    console.log('
- Удаляем:', item.text)
    await this.animMgr.animateExit(item)
    this.items = this.items.filter(i => i.id !== id)
    console.log('✓ Анимация выхода завершена, элемент удалён')
  }

  render() {
    return this.items.map(item => {
      const enterAnim = this.animMgr.entering.get(item.id)
      const exitAnim  = this.animMgr.exiting.get(item.id)
      const anim = enterAnim || exitAnim

      return {
        id: item.id,
        text: item.text,
        style: anim?.style || { opacity: 1, transform: 'none' },
        state: enterAnim ? 'entering' : exitAnim ? 'exiting' : 'stable',
      }
    })
  }
}

// --- CSS-классы подход (без RAF) ---

function createCSSAnimationHelper() {
  // Паттерн: добавить класс → дать время для transition → убрать из DOM

  const transitions = new Map()

  function enter(id, onFinish) {
    // Сначала элемент невидим
    transitions.set(id, { phase: 'entering', opacity: 0 })

    // Следующий фрейм: добавляем класс для transition
    requestAnimationFrame(() => {
      requestAnimationFrame(() => {
        transitions.set(id, { phase: 'visible', opacity: 1 })
        setTimeout(() => {
          transitions.delete(id)
          onFinish?.()
        }, 300)
      })
    })
  }

  function exit(id, onFinish) {
    transitions.set(id, { phase: 'exiting', opacity: 0 })
    setTimeout(() => {
      transitions.delete(id)
      onFinish?.()
    }, 300)
  }

  return { enter, exit, getState: (id) => transitions.get(id) }
}

// --- Тест ---

async function runDemo() {
  console.log('=== AnimatedList Demo ===')
  const list = new AnimatedList()

  const item1 = await list.add('React')
  const item2 = await list.add('TypeScript')
  const item3 = await list.add('Framer Motion')

  console.log('
Список после добавления:', list.items.map(i => i.text).join(', '))

  await list.remove(item2.id)
  console.log('После удаления TypeScript:', list.items.map(i => i.text).join(', '))

  // CSS классы подход
  console.log('
=== CSS Animation Helper ===')
  const cssAnim = createCSSAnimationHelper()
  cssAnim.enter('modal', () => console.log('Modal: анимация входа завершена'))
  cssAnim.exit('tooltip', () => console.log('Tooltip: анимация выхода завершена — удаляем из DOM'))
}

runDemo()

Анимации в React

CSS-анимации: самый простой способ

Для большинства анимаций не нужны библиотеки. CSS transitions работают при изменении класса:

// Стили:
// .item { opacity: 0; transform: translateY(10px); transition: all 0.3s ease; }
// .item.visible { opacity: 1; transform: translateY(0); }

function AnimatedItem({ isVisible, children }) {
  return (
    <div className={'item' + (isVisible ? ' visible' : '')}>
      {children}
    </div>
  )
}

CSS анимации входа:

@keyframes slideIn {
  from { opacity: 0; transform: translateX(-20px); }
  to   { opacity: 1; transform: translateX(0); }
}

.card-enter {
  animation: slideIn 0.3s ease forwards;
}

Проблема анимаций выхода

CSS transitions легко применить при входе, но сложно при выходе. Компонент размонтируется — CSS не успевает сработать.

Решение — задержать удаление из DOM:

function useDelayedUnmount(isVisible, delay = 300) {
  const [shouldRender, setShouldRender] = useState(isVisible)

  useEffect(() => {
    if (isVisible) {
      setShouldRender(true)
    } else {
      // Задерживаем удаление на время анимации выхода
      const timer = setTimeout(() => setShouldRender(false), delay)
      return () => clearTimeout(timer)
    }
  }, [isVisible, delay])

  return shouldRender
}

function AnimatedModal({ isOpen }) {
  const shouldRender = useDelayedUnmount(isOpen, 300)

  if (!shouldRender) return null

  return (
    <div className={'modal ' + (isOpen ? 'modal-enter' : 'modal-exit')}>
      Содержимое модала
    </div>
  )
}

Framer Motion: декларативные анимации

framer-motion — самая популярная библиотека анимаций для React:

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

// Базовая анимация:
<motion.div
  initial={{ opacity: 0, y: -20 }}  // начальное состояние
  animate={{ opacity: 1, y: 0 }}    // конечное состояние
  exit={{ opacity: 0, y: 20 }}      // анимация выхода
  transition={{ duration: 0.3 }}
>
  Привет, я анимирован!
</motion.div>

// AnimatePresence: для анимации монтирования/размонтирования
function NotificationList({ notifications }) {
  return (
    <AnimatePresence>
      {notifications.map(n => (
        <motion.div
          key={n.id}
          initial={{ opacity: 0, x: 100 }}
          animate={{ opacity: 1, x: 0 }}
          exit={{ opacity: 0, x: 100, height: 0 }}
          transition={{ type: 'spring', stiffness: 300 }}
        >
          {n.message}
        </motion.div>
      ))}
    </AnimatePresence>
  )
}

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

const cardVariants = {
  hidden: { opacity: 0, scale: 0.8 },
  visible: { opacity: 1, scale: 1, transition: { duration: 0.3 } },
  hover:   { scale: 1.05, boxShadow: '0 10px 20px rgba(0,0,0,0.2)' },
}

<motion.div
  variants={cardVariants}
  initial="hidden"
  animate="visible"
  whileHover="hover"
>
  Карточка с анимацией
</motion.div>

Когда CSS, когда библиотека

| Сценарий | CSS | Framer Motion |

|---|---|---|

| Hover эффекты | ✓ | — |

| Простые переходы (opacity, transform) | ✓ | — |

| Анимации входа/выхода | С хуком | ✓ |

| Drag & Drop | — | ✓ |

| Сложные последовательности | — | ✓ |

| Физические анимации (spring) | — | ✓ |

| Layout анимации | — | ✓ |

| Производительность | Отлично | Хорошо |

requestAnimationFrame для JavaScript-анимаций

Когда нужна анимация через JavaScript (не CSS):

function useAnimatedValue(targetValue, duration = 500) {
  const [value, setValue] = useState(0)

  useEffect(() => {
    const startValue = value
    const startTime = performance.now()

    function animate(currentTime) {
      const elapsed = currentTime - startTime
      const progress = Math.min(elapsed / duration, 1)

      // easeOutCubic
      const eased = 1 - Math.pow(1 - progress, 3)
      setValue(startValue + (targetValue - startValue) * eased)

      if (progress < 1) {
        requestAnimationFrame(animate)
      }
    }

    requestAnimationFrame(animate)
  }, [targetValue])

  return value
}

// Использование: анимированный счётчик
function Counter({ target }) {
  const animatedValue = useAnimatedValue(target, 1000)
  return <span>{Math.round(animatedValue)}</span>
}

Производительность анимаций

Анимируйте только GPU-свойства: transform и opacity. Избегайте анимации width, height, margin, top, left — они вызывают дорогостоящий reflow:

/* Плохо — вызывает reflow браузера: */
.box { transition: width 0.3s, left 0.3s; }

/* Хорошо — только GPU: */
.box { transition: transform 0.3s, opacity 0.3s; }
/* Вместо left/top используем translate: */
.box.moved { transform: translateX(100px); }

Примеры

Реализация системы анимированных входа/выхода элементов списка через CSS-классы и requestAnimationFrame, с измерением производительности

// Реализуем анимации добавления/удаления элементов без библиотек.
// Это концепция AnimatePresence из Framer Motion, реализованная вручную.

// --- Менеджер анимаций ---

class AnimationManager {
  constructor(options = {}) {
    this.duration = options.duration || 300   // мс
    this.entering = new Map()  // элементы в процессе входа
    this.exiting  = new Map()  // элементы в процессе выхода
    this.callbacks = []
  }

  // Анимация входа: opacity 0 → 1, translateY 10px → 0
  animateEnter(item) {
    return new Promise(resolve => {
      const animState = { progress: 0, item }
      this.entering.set(item.id, animState)

      const start = performance.now()
      const animate = (now) => {
        const elapsed = now - start
        const raw = Math.min(elapsed / this.duration, 1)
        // easeOutCubic: быстрый старт, мягкое завершение
        const progress = 1 - Math.pow(1 - raw, 3)

        animState.progress = progress
        animState.style = {
          opacity: progress,
          transform: 'translateY(' + (10 * (1 - progress)) + 'px)',
        }

        this.notify()

        if (raw < 1) {
          requestAnimationFrame(animate)
        } else {
          this.entering.delete(item.id)
          resolve()
        }
      }

      requestAnimationFrame(animate)
    })
  }

  // Анимация выхода: opacity 1 → 0, translateX 0 → 20px
  animateExit(item) {
    return new Promise(resolve => {
      const animState = { progress: 1, item }
      this.exiting.set(item.id, animState)

      const start = performance.now()
      const animate = (now) => {
        const elapsed = now - start
        const raw = Math.min(elapsed / this.duration, 1)
        // easeInCubic: медленный старт, быстрое завершение
        const progress = 1 - Math.pow(raw, 3)

        animState.progress = progress
        animState.style = {
          opacity: progress,
          transform: 'translateX(' + (20 * (1 - progress)) + 'px)',
        }

        this.notify()

        if (raw < 1) {
          requestAnimationFrame(animate)
        } else {
          this.exiting.delete(item.id)
          resolve()
        }
      }

      requestAnimationFrame(animate)
    })
  }

  notify() {
    this.callbacks.forEach(fn => fn())
  }

  subscribe(fn) {
    this.callbacks.push(fn)
  }
}

// --- AnimatedList ---

class AnimatedList {
  constructor() {
    this.items = []
    this.animMgr = new AnimationManager({ duration: 200 })  // быстрее для теста
    this.nextId = 1
  }

  async add(text) {
    const item = { id: this.nextId++, text }
    this.items.push(item)
    console.log('
+ Добавляем:', item.text)
    await this.animMgr.animateEnter(item)
    console.log('✓ Анимация входа завершена для:', item.text)
    return item
  }

  async remove(id) {
    const item = this.items.find(i => i.id === id)
    if (!item) return

    console.log('
- Удаляем:', item.text)
    await this.animMgr.animateExit(item)
    this.items = this.items.filter(i => i.id !== id)
    console.log('✓ Анимация выхода завершена, элемент удалён')
  }

  render() {
    return this.items.map(item => {
      const enterAnim = this.animMgr.entering.get(item.id)
      const exitAnim  = this.animMgr.exiting.get(item.id)
      const anim = enterAnim || exitAnim

      return {
        id: item.id,
        text: item.text,
        style: anim?.style || { opacity: 1, transform: 'none' },
        state: enterAnim ? 'entering' : exitAnim ? 'exiting' : 'stable',
      }
    })
  }
}

// --- CSS-классы подход (без RAF) ---

function createCSSAnimationHelper() {
  // Паттерн: добавить класс → дать время для transition → убрать из DOM

  const transitions = new Map()

  function enter(id, onFinish) {
    // Сначала элемент невидим
    transitions.set(id, { phase: 'entering', opacity: 0 })

    // Следующий фрейм: добавляем класс для transition
    requestAnimationFrame(() => {
      requestAnimationFrame(() => {
        transitions.set(id, { phase: 'visible', opacity: 1 })
        setTimeout(() => {
          transitions.delete(id)
          onFinish?.()
        }, 300)
      })
    })
  }

  function exit(id, onFinish) {
    transitions.set(id, { phase: 'exiting', opacity: 0 })
    setTimeout(() => {
      transitions.delete(id)
      onFinish?.()
    }, 300)
  }

  return { enter, exit, getState: (id) => transitions.get(id) }
}

// --- Тест ---

async function runDemo() {
  console.log('=== AnimatedList Demo ===')
  const list = new AnimatedList()

  const item1 = await list.add('React')
  const item2 = await list.add('TypeScript')
  const item3 = await list.add('Framer Motion')

  console.log('
Список после добавления:', list.items.map(i => i.text).join(', '))

  await list.remove(item2.id)
  console.log('После удаления TypeScript:', list.items.map(i => i.text).join(', '))

  // CSS классы подход
  console.log('
=== CSS Animation Helper ===')
  const cssAnim = createCSSAnimationHelper()
  cssAnim.enter('modal', () => console.log('Modal: анимация входа завершена'))
  cssAnim.exit('tooltip', () => console.log('Tooltip: анимация выхода завершена — удаляем из DOM'))
}

runDemo()

Задание

Создай компонент `NotificationStack` — стек уведомлений с CSS-анимациями входа и выхода. Используй `useState` для хранения списка уведомлений со статусом анимации. При добавлении уведомление получает состояние "entering", через 300мс переходит в "stable". При удалении — "exiting", через 300мс удаляется из списка. Заполни пропуски `???`.

Подсказка

useState для notifications, useRef для nextId (не вызывает ре-рендер), "entering" при добавлении, "exiting" при удалении, filter для удаления элемента из массива.

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