У класичному браузерному конвеєрі рендерингу (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 транслює будь-які зміни компонування (додавання елементів, фільтрація списків, переходи спільних елементів) в апаратні 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>
{/* Модальний оверлей із переходом спільного елемента */}
<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 забезпечує оптимальний розмір бандла.