PatternsLists & Performance
FLIP Animations
Use the First, Last, Invert, Play technique to create performant animations when list items change position.
FLIP Animations
Problem
Animating DOM elements that change position (reordering, sorting, filtering) is challenging. Naive approaches cause layout thrashing and jank.
Solution
Use the FLIP technique (First, Last, Invert, Play):
- First: Record initial position
- Last: Apply changes and record final position
- Invert: Apply transform to make element appear in the first position
- Play: Remove transform and animate to final position
interface ElementPosition {
x: number;
y: number;
width: number;
height: number;
}
interface FlipAnimation {
element: HTMLElement;
first: ElementPosition;
last: ElementPosition;
deltaX: number;
deltaY: number;
}
/**
* Get element's position and size
*/
function getElementPosition(element: HTMLElement): ElementPosition {
const rect = element.getBoundingClientRect();
return {
x: rect.left,
y: rect.top,
width: rect.width,
height: rect.height,
};
}
/**
* Calculate position delta
*/
function calculateDelta(
first: ElementPosition,
last: ElementPosition
): { deltaX: number; deltaY: number } {
return {
deltaX: first.x - last.x,
deltaY: first.y - last.y,
};
}
class FlipAnimator {
private animations = new Map<HTMLElement, FlipAnimation>();
private duration: number;
private easing: string;
constructor(duration = 300, easing = 'cubic-bezier(0.25, 0.46, 0.45, 0.94)') {
this.duration = duration;
this.easing = easing;
}
/**
* Step 1: Record FIRST positions
*/
public recordFirst(elements: HTMLElement[]): void {
this.animations.clear();
elements.forEach((element) => {
const first = getElementPosition(element);
this.animations.set(element, {
element,
first,
last: first, // Will be updated in recordLast
deltaX: 0,
deltaY: 0,
});
});
}
/**
* Step 2: Record LAST positions (after DOM changes)
* Step 3: INVERT - calculate deltas
*/
public recordLast(): void {
this.animations.forEach((animation) => {
animation.last = getElementPosition(animation.element);
const delta = calculateDelta(animation.first, animation.last);
animation.deltaX = delta.deltaX;
animation.deltaY = delta.deltaY;
});
}
/**
* Step 4: PLAY - animate from inverted to final position
*/
public async play(): Promise<void> {
const promises: Promise<void>[] = [];
this.animations.forEach((animation) => {
const { element, deltaX, deltaY } = animation;
// Skip if element hasn't moved
if (deltaX === 0 && deltaY === 0) {
return;
}
// Apply INVERT transform immediately (no transition)
element.style.transition = 'none';
element.style.transform = `translate(${deltaX}px, ${deltaY}px)`;
// Force reflow
element.offsetHeight;
// PLAY - animate to final position
element.style.transition = `transform ${this.duration}ms ${this.easing}`;
element.style.transform = 'translate(0, 0)';
// Create promise that resolves when animation completes
const promise = new Promise<void>((resolve) => {
const handleTransitionEnd = (): void => {
element.removeEventListener('transitionend', handleTransitionEnd);
element.style.transition = '';
element.style.transform = '';
resolve();
};
element.addEventListener('transitionend', handleTransitionEnd, { once: true });
// Fallback timeout
setTimeout(() => {
element.removeEventListener('transitionend', handleTransitionEnd);
element.style.transition = '';
element.style.transform = '';
resolve();
}, this.duration + 50);
});
promises.push(promise);
});
await Promise.all(promises);
this.animations.clear();
}
/**
* Complete FLIP animation sequence
*/
public async animate(
elements: HTMLElement[],
changeCallback: () => void
): Promise<void> {
// Step 1: Record FIRST
this.recordFirst(elements);
// Apply DOM changes
changeCallback();
// Small delay to ensure DOM has updated
await new Promise((resolve) => requestAnimationFrame(resolve));
// Step 2 & 3: Record LAST and calculate INVERT
this.recordLast();
// Step 4: PLAY animation
await this.play();
}
}
// Practical example: Sortable list
interface ListItem {
id: string;
text: string;
order: number;
}
class SortableList {
private animator: FlipAnimator;
private container: HTMLElement;
private items: ListItem[];
constructor(container: HTMLElement, items: ListItem[]) {
this.container = container;
this.items = items;
this.animator = new FlipAnimator(400);
this.render();
}
private render(): void {
this.container.innerHTML = '';
this.items.forEach((item) => {
const element = document.createElement('div');
element.className = 'list-item';
element.dataset.id = item.id;
element.textContent = item.text;
this.container.appendChild(element);
});
}
private getElements(): HTMLElement[] {
return Array.from(this.container.querySelectorAll('.list-item')) as HTMLElement[];
}
public async sortBy(key: keyof ListItem, direction: 'asc' | 'desc' = 'asc'): Promise<void> {
const elements = this.getElements();
await this.animator.animate(elements, () => {
// Sort data
this.items.sort((a, b) => {
const valueA = a[key];
const valueB = b[key];
if (typeof valueA === 'string' && typeof valueB === 'string') {
return direction === 'asc'
? valueA.localeCompare(valueB)
: valueB.localeCompare(valueA);
}
if (typeof valueA === 'number' && typeof valueB === 'number') {
return direction === 'asc' ? valueA - valueB : valueB - valueA;
}
return 0;
});
// Re-render with new order
this.render();
});
}
public async shuffle(): Promise<void> {
const elements = this.getElements();
await this.animator.animate(elements, () => {
// Shuffle array
for (let i = this.items.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[this.items[i], this.items[j]] = [this.items[j], this.items[i]];
}
this.render();
});
}
}
// Usage
const items: ListItem[] = [
{ id: '1', text: 'Apple', order: 1 },
{ id: '2', text: 'Banana', order: 2 },
{ id: '3', text: 'Cherry', order: 3 },
{ id: '4', text: 'Date', order: 4 },
];
const container = document.getElementById('list') as HTMLElement;
const sortableList = new SortableList(container, items);
// Sort by text
document.getElementById('sort-btn')?.addEventListener('click', () => {
sortableList.sortBy('text', 'asc');
});
// Shuffle
document.getElementById('shuffle-btn')?.addEventListener('click', () => {
sortableList.shuffle();
});Performance Note
FLIP animations are GPU-accelerated because they only use transform properties. This means:
- 60fps smooth animations: no layout recalculations during animation
- No reflows: transforms happen on the compositor thread
- Battery efficient: GPU handles the work, CPU stays free
Why FLIP works:
- Measuring positions (getBoundingClientRect) is fast when done in batch
- Transform-only animations bypass layout and paint
- Browser compositor handles the interpolation
Performance comparison (reordering 50 items):
- Naive approach (animating top/left): 15fps, janky
- FLIP with transforms: 60fps, smooth
FLIP is ideal for:
- Drag and drop reordering
- Sort animations
- Filter transitions (items appearing/disappearing)
- Grid layout changes
Note: FLIP doesn't work well when element sizes change significantly. For those cases, combine FLIP (for position) with opacity/scale animations.