Для большинства анимаций не нужны библиотеки. 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 — самая популярная библиотека анимаций для 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>
)
}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 | Framer Motion |
|---|---|---|
| Hover эффекты | ✓ | — |
| Простые переходы (opacity, transform) | ✓ | — |
| Анимации входа/выхода | С хуком | ✓ |
| Drag & Drop | — | ✓ |
| Сложные последовательности | — | ✓ |
| Физические анимации (spring) | — | ✓ |
| Layout анимации | — | ✓ |
| Производительность | Отлично | Хорошо |
Когда нужна анимация через 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()Для большинства анимаций не нужны библиотеки. 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 — самая популярная библиотека анимаций для 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>
)
}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 | Framer Motion |
|---|---|---|
| Hover эффекты | ✓ | — |
| Простые переходы (opacity, transform) | ✓ | — |
| Анимации входа/выхода | С хуком | ✓ |
| Drag & Drop | — | ✓ |
| Сложные последовательности | — | ✓ |
| Физические анимации (spring) | — | ✓ |
| Layout анимации | — | ✓ |
| Производительность | Отлично | Хорошо |
Когда нужна анимация через 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 для удаления элемента из массива.