В классическом веб-конвейере рендеринга (Critical Rendering Path) операции изменения геометрических параметров элементов (width, height, top, left, margin, padding) являются наиболее ресурсоемкими и опасными для производительности. Любая модификация геометрии заставляет браузер инициировать вынужденный каскадный пересчет координат всех элементов на странице — Layout (Reflow).
Чередование синхронного чтения размеров (element.offsetHeight, getBoundingClientRect) и последующей мутации геометрии внутри одного кадра порождает Layout Thrashing. В результате частота обновления кадров обваливается со 120 FPS до дерганых 20–30 FPS, а задержка ввода пользователя (INP) деградирует в критическую «красную» зону (более 200 мс).
Современный движок Motion (в версиях 11+ библиотека Framer Motion перешла на компактный модульный пакет motion) кардинально меняет парадигму анимирования раскладки. Вместо мутации свойств геометрии Motion реализует фундаментальный паттерн FLIP (First, Last, Invert, Play). Библиотека замеряет исходное и конечное положение DOM-элементов, инвертирует разницу через матричные трансформации и выполняет интерполяцию исключительно силами аппаратного ускорителя GPU в потоке композитинга (Compositor Thread) с нулевыми затратами на Reflow.
Традиционная CSS-анимация свойств width/height или top/left неизбежно перегружает процессор вычислениями раскладки на каждый кадр. Движок Motion переводит любые изменения раскладки (добавление элементов, фильтрация списков, shared element transitions) в аппаратные GPU-трансформации (transform и opacity), гарантируя стабильные 60–120 FPS и мгновенную реакцию интерфейса на жесты пользователя.
В основе Motion лежит конвейерная обработка четырех фаз алгоритма FLIP в связке с физическим движком пружин (Spring Physics):
┌────────────────────────────────────────────────────────────────────────┐
│ МАТЕМАТИЧЕСКИЙ КОНВЕЙЕР ТЕХНИКИ FLIP │
├────────────────────────────────────────────────────────────────────────┤
│ 1. FIRST: Замер начальных координат: box1 = el.getBoundingClientRect()│
│ 2. LAST: Применение нового состояния в DOM: box2 = el.getBCR() │
│ 3. INVERT: Расчет дельта-матрицы смещения и масштаба: │
│ dx = box1.left - box2.left; sx = box1.width / box2.width │
│ Применение инверсии: transform: translate(dx) scale(sx) │
│ 4. PLAY: Аппаратное снятие дельты к transform: identity (GPU) │
└───────────────────────────────────┬────────────────────────────────────┘
│
▼ (Compositor Thread)
┌────────────────────────────────────────────────────────────────────────┐
│ Результат: 120 FPS, нулевой пересчет Layout, аппаратная плавность │
└────────────────────────────────────────────────────────────────────────┘layout: Проп, указывающий Motion отслеживать геометрические изменения узла. Если карточка изменила размер или позицию из-за добавления соседнего элемента в CSS Flexbox/Grid, Motion автоматически сгладит этот переход через FLIP.if (isOpen) и позволяет отыграть фазу exit.cubic-bezier), пружина сохраняет скорость броска (velocity) при прерывании пользователем.Пользователь кликает по карточке: setSelectedId(42). React 19 планирует рендер нового интерфейса.
Motion синхронно считывает размеры исходной миниатюры (First), монтирует полноразмерную карточку и считывает ее габариты (Last).
Motion вычисляет дельту scale/translate и накладывает ее через CSS transform на новый элемент. Визуально карточка оказывается на месте старой.
Пружинный интегратор покадрово интерполирует матрицу трансформации к нулю (identity) в потоке Compositor Thread на 120 FPS.
Почему анимация перемещения и изменения размера элемента через проп layout в Motion работает значительно плавнее (120 FPS), чем аналогичная анимация через классический CSS-переход transition: width 0.3s, left 0.3s?
В мобильных и веб-приложениях клик по товару не должен сопровождаться мерцанием или белым экраном. Карточка плавно разворачивается из сетки в модальное окно на весь экран с сохранением фокуса, визуального контекста и плавным проявлением дополнительного контента.
При переключении вкладок меню активный маркер не перерисовывается скачком, а плавно «перетекает» под выбранный пункт благодаря единому layoutId.
При перетаскивании карточки в Trello/Jira соседние задачи плавно расступаются, освобождая место. Применение layout="position" обеспечивает нулевую задержку пересчета позиций при рендеринге сотен карточек.
Шторки и тосты, которые пользователь может физически тянуть пальцем. Если бросить шторку с высокой скоростью вниз, пружинный интегратор подхватит накопленный импульс (velocity) и закроет панель с кинетической реалистичностью.
В классической CSS-анимации время задается жестко: transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1). Если пользователь прерывает жест на середине движения, CSS либо сбрасывает скорость в ноль, либо вызывает визуальный скачок кадра.
В Motion движение описывается формулой пружинного осциллятора:
F = -k * x - c * vгде:
k (stiffness, жесткость): сила, с которой пружина тянет объект к целевой точке. Чем выше жесткость, тем быстрее разгон;c (damping, демпфирование): сила трения среды. При значении демпфирования ниже критического порога возникают упругие колебания (bounce);m (mass, масса): инерция тела;v (velocity, начальная скорость): вектор скорости пальца или курсора мыши в момент отпускания элемента.При масштабировании через transform: scale() дочерний текст размывается, а скругления углов (border-radius) овально вытягиваются. Motion решает это разделением ответственности:
[Родительский контейнер]: layout (анимирует размеры и положение через FLIP)
│
└── [Дочерний текст]: layout="position" (компенсирует родительский scale,
смещаясь без искажения шрифта)В панели уведомлений карточка удаляется по клику, а соседние карточки снизу должны моментально плавно подняться вверх на освободившееся место, в то время как удаляемая карточка плавно растворяется и улетает вправо. Какой режим AnimatePresence следует использовать?
Разработаем два высокопроизводительных производственных компонента на React 19:
src/components/navigation/SlidingTabsNav.tsx: Навигационные вкладки со скользящим физическим индикатором на базе layoutId и нулевым Layout Thrashing.src/components/gallery/ExpandableCardModal.tsx: Полнофункциональная галерея с Shared Element Transitions, пружинной физикой жеста свайпа для закрытия и компенсацией искажений текста.'use client';
import * as React from 'react';
import { motion } from 'motion/react';
export interface TabItem {
readonly id: string;
readonly label: string;
readonly badge?: number;
}
interface SlidingTabsNavProps {
readonly tabs: readonly TabItem[];
readonly defaultTabId?: string;
readonly onChange?: (tabId: string) => void;
}
export function SlidingTabsNav({
tabs,
defaultTabId,
onChange,
}: SlidingTabsNavProps) {
const [activeTab, setActiveTab] = React.useState<string>(
defaultTabId ?? tabs[0]?.id ?? ''
);
const handleSelect = (id: string) => {
setActiveTab(id);
onChange?.(id);
};
return (
<nav aria-label="Панель разделов" className="inline-flex p-1.5 rounded-2xl bg-bg-surface border border-border shadow-inner">
<div className="flex items-center gap-1">
{tabs.map((tab) => {
const isActive = tab.id === activeTab;
return (
<button
key={tab.id}
type="button"
onClick={() => handleSelect(tab.id)}
className={`relative px-4 py-2 text-xs font-semibold rounded-xl transition-colors duration-200 outline-none focus-visible:ring-2 focus-visible:ring-accent-violet cursor-pointer ${
isActive ? 'text-white' : 'text-text-muted hover:text-text-primary'
}`}
>
{/* Плавающий маркер активного таба на GPU */}
{isActive && (
<motion.div
layoutId="active-tab-pill-marker"
className="absolute inset-0 rounded-xl bg-accent-violet/30 border border-accent-violet/50 shadow-sm"
transition={{
type: 'spring',
stiffness: 450,
damping: 32,
mass: 0.8,
}}
/>
)}
{/* Контент вкладки */}
<span className="relative z-10 flex items-center gap-2">
<span>{tab.label}</span>
{typeof tab.badge === 'number' && (
<span
className={`text-[10px] font-mono px-1.5 py-0.5 rounded-full ${
isActive
? 'bg-accent-violet text-white'
: 'bg-bg-elevated text-text-muted'
}`}
>
{tab.badge}
</span>
)}
</span>
</button>
);
})}
</div>
</nav>
);
}'use client';
import * as React from 'react';
import { motion, AnimatePresence, type PanInfo } from 'motion/react';
export interface CardItem {
readonly id: string;
readonly title: string;
readonly category: string;
readonly description: string;
readonly tag: string;
}
const SAMPLE_CARDS: readonly CardItem[] = [
{
id: 'card-1',
title: 'React 19 Concurrent Architecture',
category: 'Архитектура',
tag: 'Fiber & Lanes',
description: 'Глубокий анализ планировщика React Fiber, приоритетов выполнения дорожек (Lanes) и селективной гидратации.',
},
{
id: 'card-2',
title: 'Motion FLIP Engine',
category: 'Графика',
tag: 'Compositor 120 FPS',
description: 'Математический конвейер трансформаций в потоке композитинга браузера с нулевым пересчетом геометрии DOM.',
},
{
id: 'card-3',
title: 'Local-First & CRDT Replication',
category: 'Базы данных',
tag: 'Yjs & Dexie',
description: 'Бесконфликтная синхронизация документов между браузерными хранилищами IndexedDB через WebSockets и WebRTC.',
},
];
export function ExpandableCardModal() {
const [selectedId, setSelectedId] = React.useState<string | null>(null);
const activeCard = SAMPLE_CARDS.find((c) => c.id === selectedId);
// Закрытие модального окна по свайпу вниз
const handleDragEnd = (_event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
if (info.offset.y > 100 || info.velocity.y > 400) {
setSelectedId(null);
}
};
return (
<div className="relative max-w-4xl mx-auto p-6 space-y-6">
{/* Сетка миниатюр */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
{SAMPLE_CARDS.map((card) => (
<motion.div
key={card.id}
layoutId={`card-container-${card.id}`}
onClick={() => setSelectedId(card.id)}
whileHover={{ y: -4, transition: { duration: 0.15 } }}
whileTap={{ scale: 0.98 }}
className="cursor-pointer p-6 rounded-2xl bg-bg-surface border border-border hover:border-accent-violet/40 transition-colors shadow-lg space-y-3"
transition={{
type: 'spring',
stiffness: 350,
damping: 28,
}}
>
<motion.div
layoutId={`card-badge-${card.id}`}
className="inline-block text-[10px] font-mono font-bold uppercase tracking-wider px-2 py-0.5 rounded-full bg-accent-violet/15 text-accent-violet border border-accent-violet/20"
>
{card.category}
</motion.div>
{/* layout='position' предотвращает искажение шрифта при родительском scale */}
<motion.h3
layout="position"
layoutId={`card-title-${card.id}`}
className="font-bold text-base text-text-primary leading-snug"
>
{card.title}
</motion.h3>
<motion.p
layout="position"
className="text-xs text-text-secondary line-clamp-2"
>
{card.description}
</motion.p>
</motion.div>
))}
</div>
{/* Модальный оверлей с shared element переходом */}
<AnimatePresence>
{activeCard && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Полупрозрачный бэкдроп */}
<motion.div
key="backdrop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setSelectedId(null)}
className="absolute inset-0 bg-black/75 backdrop-blur-md cursor-pointer"
/>
{/* Развернутая карточка со свайпом закрытия */}
<motion.div
layoutId={`card-container-${activeCard.id}`}
drag="y"
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={0.6}
onDragEnd={handleDragEnd}
className="relative z-10 w-full max-w-lg p-8 rounded-3xl bg-bg-surface border border-accent-violet/40 shadow-2xl space-y-6 touch-none"
transition={{
type: 'spring',
stiffness: 300,
damping: 30,
}}
>
{/* Полоска-индикатор для свайпа */}
<div className="w-12 h-1.5 rounded-full bg-border mx-auto -mt-2 opacity-60" />
<div className="flex items-start justify-between gap-4">
<div className="space-y-2">
<motion.div
layoutId={`card-badge-${activeCard.id}`}
className="inline-block text-[10px] font-mono font-bold uppercase tracking-wider px-2.5 py-0.5 rounded-full bg-accent-violet/15 text-accent-violet border border-accent-violet/20"
>
{activeCard.category} • {activeCard.tag}
</motion.div>
<motion.h2
layout="position"
layoutId={`card-title-${activeCard.id}`}
className="text-2xl font-black text-text-primary tracking-tight"
>
{activeCard.title}
</motion.h2>
</div>
<button
type="button"
onClick={() => setSelectedId(null)}
className="px-3 py-1.5 text-xs font-mono rounded-xl bg-bg-elevated border border-border text-text-muted hover:text-text-primary transition-colors cursor-pointer"
>
ESC
</button>
</div>
{/* Детальный контент, плавно появляющийся после раскрытия */}
<motion.div
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 5 }}
transition={{ delay: 0.08, duration: 0.2 }}
className="space-y-4 pt-4 border-t border-border text-sm text-text-secondary leading-relaxed"
>
<p>{activeCard.description}</p>
<p className="text-xs text-text-muted">
Потяните карточку вниз для закрытия или нажмите клавишу ESC / кликните по фону. Анимация выполнена по алгоритму FLIP без пересчета геометрии.
</p>
</motion.div>
</motion.div>
</div>
)}
</AnimatePresence>
</div>
);
}Попытка вручную анимировать animate={{ width: isOpen ? 400 : 100 }} заставляет браузер вызывать дорогостоящую фазу Layout на каждый тик таймера.
Решение: Используйте директиву layout и меняйте классы ширины в Tailwind, позволяя Motion автоматически транслировать изменения в GPU-матрицы FLIP.
Если элементы внутри AnimatePresence не имеют уникального стабильного свойства key, React Fiber не может сопоставить удаляемый узел, в результате чего элемент исчезает моментально без выполнения фазы exit.
Решение: Всегда указывайте уникальный key для анимируемых детей AnimatePresence.
Поскольку техника FLIP использует transform: scale(), вложенный текст растягивается и становится размытым во время морфинга.
Решение: Всегда добавляйте layout="position" к дочерним текстовым заголовкам и абзацам, чтобы они перемещались без растяжения.
Импорт import { motion } from 'framer-motion' втягивает устаревший монолитный бандл размером свыше 35 КБ.
Решение: В проектах на Framer Motion 11+ используйте современную оптимизированную точку входа import { motion } from 'motion/react'.
transform: translate / scale) в потоке композитинга.layoutId связывает независимые компоненты в дереве React, обеспечивая бесшовный кинетический морфинг (Shared Element Transition) между карточками каталога и модальными окнами.AnimatePresence координирует фазы exit, а режим mode="popLayout" позволяет соседним элементам моментально занимать освободившееся место через position: absolute.stiffness), демпфирования (damping) и массы (mass), сохраняя входящий вектор скорости (velocity) при прерывании жестов пользователем.layout="position", а импорт из motion/react обеспечивает оптимальный размер бандла.