Реализация Framer Motion анимаций в React-приложении
Framer Motion — декларативная библиотека анимаций для React. В отличие от GSAP, она интегрирована в жизненный цикл React: анимации монтирования/размонтирования работают нативно, состояние анимации синхронизировано с состоянием компонента. Это делает её предпочтительным выбором для React-приложений, где анимации тесно связаны с UI-логикой.
Установка
npm install framer-motion
Для Next.js 13+ с App Router: компоненты с motion должны быть "use client". Framer Motion не работает в RSC.
Основные паттерны
motion-компоненты и variants
Variants — декларативный способ описать состояния анимации:
// components/AnimatedCard.tsx
'use client'
import { motion, Variants } from 'framer-motion'
const cardVariants: Variants = {
hidden: {
opacity: 0,
y: 30,
scale: 0.97,
},
visible: {
opacity: 1,
y: 0,
scale: 1,
transition: {
duration: 0.5,
ease: [0.25, 0.46, 0.45, 0.94], // custom cubic-bezier
},
},
hover: {
y: -4,
boxShadow: '0 20px 40px rgba(0,0,0,0.12)',
transition: { duration: 0.2, ease: 'easeOut' },
},
tap: {
scale: 0.98,
transition: { duration: 0.1 },
},
}
interface AnimatedCardProps {
children: React.ReactNode
delay?: number
}
export function AnimatedCard({ children, delay = 0 }: AnimatedCardProps) {
return (
<motion.div
variants={cardVariants}
initial="hidden"
animate="visible"
whileHover="hover"
whileTap="tap"
transition={{ delay }}
className="bg-white rounded-xl p-6 shadow-md cursor-pointer"
>
{children}
</motion.div>
)
}
Stagger-анимация дочерних элементов
Framer Motion автоматически пробрасывает variants в дочерние компоненты через контекст — не нужно передавать variant явно:
// components/AnimatedList.tsx
'use client'
import { motion, Variants } from 'framer-motion'
const containerVariants: Variants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.08, // задержка между дочерними
delayChildren: 0.2,
},
},
}
const itemVariants: Variants = {
hidden: { opacity: 0, x: -20 },
visible: {
opacity: 1,
x: 0,
transition: { duration: 0.4, ease: 'easeOut' },
},
}
export function AnimatedList({ items }: { items: string[] }) {
return (
<motion.ul
variants={containerVariants}
initial="hidden"
animate="visible"
>
{items.map((item, i) => (
<motion.li key={i} variants={itemVariants}>
{item}
</motion.li>
))}
</motion.ul>
)
}
AnimatePresence: анимация размонтирования
Стандартный React не позволяет анимировать компоненты при unmount — они просто исчезают. AnimatePresence решает это:
// components/Modal.tsx
'use client'
import { AnimatePresence, motion } from 'framer-motion'
interface ModalProps {
isOpen: boolean
onClose: () => void
children: React.ReactNode
}
const overlayVariants = {
hidden: { opacity: 0 },
visible: { opacity: 1 },
}
const modalVariants = {
hidden: { opacity: 0, scale: 0.95, y: -20 },
visible: {
opacity: 1,
scale: 1,
y: 0,
transition: { type: 'spring', stiffness: 300, damping: 30 },
},
exit: {
opacity: 0,
scale: 0.95,
y: -10,
transition: { duration: 0.15 },
},
}
export function Modal({ isOpen, onClose, children }: ModalProps) {
return (
<AnimatePresence>
{isOpen && (
<>
<motion.div
key="overlay"
className="fixed inset-0 bg-black/50 z-40"
variants={overlayVariants}
initial="hidden"
animate="visible"
exit="hidden"
onClick={onClose}
/>
<motion.div
key="modal"
className="fixed inset-0 flex items-center justify-center z-50 pointer-events-none"
>
<motion.div
className="bg-white rounded-2xl p-8 max-w-lg w-full mx-4 pointer-events-auto"
variants={modalVariants}
initial="hidden"
animate="visible"
exit="exit"
>
{children}
</motion.div>
</motion.div>
</>
)}
</AnimatePresence>
)
}
useMotionValue и useTransform
Для анимаций, привязанных к движению мыши или скроллу:
// components/MagneticButton.tsx
'use client'
import { useRef, useState } from 'react'
import { motion, useMotionValue, useSpring, useTransform } from 'framer-motion'
export function MagneticButton({ children }: { children: React.ReactNode }) {
const ref = useRef<HTMLButtonElement>(null)
const x = useMotionValue(0)
const y = useMotionValue(0)
// Пружинная физика для плавности
const springConfig = { stiffness: 200, damping: 20 }
const springX = useSpring(x, springConfig)
const springY = useSpring(y, springConfig)
const handleMouseMove = (e: React.MouseEvent) => {
const rect = ref.current!.getBoundingClientRect()
const cx = rect.left + rect.width / 2
const cy = rect.top + rect.height / 2
const strength = 0.3
x.set((e.clientX - cx) * strength)
y.set((e.clientY - cy) * strength)
}
const handleMouseLeave = () => {
x.set(0)
y.set(0)
}
return (
<motion.button
ref={ref}
style={{ x: springX, y: springY }}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
className="px-6 py-3 bg-black text-white rounded-full font-medium"
>
{children}
</motion.button>
)
}
Scroll-анимации через useScroll
// components/ProgressBar.tsx
'use client'
import { useScroll, useSpring, motion } from 'framer-motion'
export function ReadingProgressBar() {
const { scrollYProgress } = useScroll()
const scaleX = useSpring(scrollYProgress, {
stiffness: 100,
damping: 30,
restDelta: 0.001,
})
return (
<motion.div
style={{ scaleX, transformOrigin: '0%' }}
className="fixed top-0 left-0 right-0 h-1 bg-blue-500 z-50"
/>
)
}
Анимация секции при скролле с whileInView:
// components/FadeInSection.tsx
'use client'
import { motion } from 'framer-motion'
export function FadeInSection({ children }: { children: React.ReactNode }) {
return (
<motion.div
initial={{ opacity: 0, y: 40 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-100px' }}
transition={{ duration: 0.6, ease: 'easeOut' }}
>
{children}
</motion.div>
)
}
viewport.once: true — анимация срабатывает только один раз. margin: '-100px' — начинает анимацию за 100px до попадания в viewport.
Shared Layout анимации (layoutId)
Плавные переходы между элементами через layoutId — Framer Motion отслеживает DOM-позицию и интерполирует:
// components/TabsWithAnimation.tsx
'use client'
import { useState } from 'react'
import { motion } from 'framer-motion'
const tabs = ['Обзор', 'Функции', 'Цены']
export function AnimatedTabs() {
const [active, setActive] = useState(0)
return (
<div className="flex gap-1 bg-gray-100 p-1 rounded-lg w-fit">
{tabs.map((tab, i) => (
<button
key={tab}
onClick={() => setActive(i)}
className="relative px-4 py-2 text-sm font-medium z-10"
>
{active === i && (
<motion.div
layoutId="active-tab" // уникальный ID для tracking
className="absolute inset-0 bg-white rounded-md shadow-sm"
transition={{ type: 'spring', stiffness: 400, damping: 35 }}
/>
)}
<span className="relative">{tab}</span>
</button>
))}
</div>
)
}
Типичные сроки
Набор базовых анимаций (fade-in, stagger, hover) — 1 рабочий день. AnimatePresence для модалок/дроверов/роутинга + layout-анимации + scroll-эффекты — 3–4 рабочих дня. Сложные интерактивные сцены с useMotionValue и физическими пружинами — от 5 дней.







