CSS Animations vs JavaScript: Layout Thrashing and the FLIP Technique
JavaScript animations cause layout thrashing—learn CSS tricks and FLIP to keep animations compositor-safe
Use requestAnimationFrame to animate DOM elements—watch your animations jank as each frame triggers layout recalculation. Read element.offsetHeight to measure, then modify element.style.height—you've forced a synchronous layout, blocking the main thread. JavaScript animations promise precision and control, but one mistimed DOM read causes layout thrashing that CSS animations avoid entirely. The browser's rendering pipeline punishes imperative DOM manipulation.
The Layout Thrashing Problem
JavaScript animations manipulate the DOM frame-by-frame. Each requestAnimationFrame callback has 16.67ms (at 60fps) to execute JavaScript, recalculate styles, perform layout, paint, and composite. When you read a layout property (offsetHeight, getBoundingClientRect) after modifying styles, the browser must immediately recalculate layout—blocking all other work.
The Classic Mistake
// Animating a list of elements - WRONG
function animateList() {
requestAnimationFrame(() => {
elements.forEach(el => {
// Read (forces layout)
const height = el.offsetHeight;
// Write (invalidates layout)
el.style.height = (height + 10) + 'px';
});
});
}
/* What happens per frame:
1. Read el[0].offsetHeight → Browser calculates layout
2. Write el[0].style.height → Invalidates layout
3. Read el[1].offsetHeight → Browser RECALCULATES layout (forced!)
4. Write el[1].style.height → Invalidates layout
5. Read el[2].offsetHeight → RECALCULATES layout again
... repeat for every element
Result: Layout calculated N times (N = number of elements)
Frame time: 16ms → 60ms+ (dropped frames, jank)
*/Google's Core Web Vitals Impact (2025)
Layout thrashing directly impacts First Input Delay (FID) and Interaction to Next Paint (INP). When users click during a layout thrashing loop, the main thread is blocked—input handling delays by 50-100ms, failing INP thresholds (<200ms).
Long Animation Frame API (2025): Chrome DevTools now exposes forcedStyleAndLayoutDuration to identify forced synchronous layouts in production. This metric appears in the Performance tab and Real User Monitoring (RUM) tools.
The Batched Read/Write Fix
// Correct approach: batch reads, then batch writes
function animateListCorrectly() {
requestAnimationFrame(() => {
// Phase 1: Read all values (single layout calculation)
const heights = elements.map(el => el.offsetHeight);
// Phase 2: Write all changes (layout happens once at end)
elements.forEach((el, i) => {
el.style.height = (heights[i] + 10) + 'px';
});
});
}
/* Optimized timeline:
1. Read all offsetHeight values → Browser calculates layout ONCE
2. Write all style.height changes → Invalidates layout
3. Browser recalculates layout once after frame completes
Result: 2 layout calculations total (vs N)
Frame time: 2ms (smooth 60fps)
*/The catch: This only works for animations where the write doesn't depend on the read. If you need to measure after modifying styles, you're back to forced layouts.
Why CSS Animations Avoid This
CSS animations and transitions run on the compositor thread—separate from the main JavaScript thread. When you animate properties that can be composited (transform, opacity), the browser doesn't recalculate layout or paint.
/* CSS animation - runs on compositor */
.element {
transition: transform 0.3s ease-out;
}
.element:hover {
transform: translateY(-10px);
}
/* Browser timeline:
1. Main thread: Parse CSS, build render tree
2. Compositor thread: Create layers for animated elements
3. GPU: Apply transform matrices (no layout, no paint)
Main thread: FREE (JavaScript can run)
Result: Butter-smooth 60fps even while JS executes
*/The Four Composite-Safe Properties
Only these properties can be animated without triggering layout or paint:
- transform: translate, scale, rotate, skew (uses matrix math on GPU)
- opacity: Composited as layer blend operation
Everything else triggers layout and/or paint:
/* These all block the main thread */
.bad-animation {
animation: expand 1s;
}
@keyframes expand {
from { width: 100px; } /* Layout + paint every frame */
to { width: 200px; }
}
.also-bad {
animation: colorize 1s;
}
@keyframes colorize {
from { background: red; } /* Paint every frame (no layout) */
to { background: blue; }
}
/* These run on compositor (smooth) */
.good-animation {
animation: slide 1s;
}
@keyframes slide {
from { transform: translateX(0); } /* Compositor only */
to { transform: translateX(100px); }
}
.also-good {
animation: fade 1s;
}
@keyframes fade {
from { opacity: 0; } /* Compositor only */
to { opacity: 1; }
}CSS Animation Tricks: Practical Examples
1. Skeleton Loaders (Pure CSS)
Skeleton screens show content placeholders while data loads. Pure CSS implementation avoids JavaScript overhead.
<!-- HTML -->
<div class="skeleton-card">
<div class="skeleton-avatar"></div>
<div class="skeleton-line"></div>
<div class="skeleton-line short"></div>
</div>
<!-- CSS -->
<style>
.skeleton-card {
padding: 20px;
background: white;
border-radius: 8px;
}
.skeleton-avatar,
.skeleton-line {
background: linear-gradient(
90deg,
hsl(200, 20%, 80%) 0%,
hsl(200, 20%, 95%) 50%,
hsl(200, 20%, 80%) 100%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
}
.skeleton-avatar {
width: 60px;
height: 60px;
border-radius: 50%;
margin-bottom: 12px;
}
.skeleton-line {
height: 16px;
margin-bottom: 8px;
}
.skeleton-line.short {
width: 60%;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* Performance: */
/* - No JavaScript = no main thread blocking
- background-position doesn't trigger layout/paint
- Runs on compositor (smooth 60fps)
- Works even if main thread is busy loading data
*/
</style>Edge case: Users with vestibular motion disorders need reduced motion. Always respect prefers-reduced-motion:
@media (prefers-reduced-motion: reduce) {
.skeleton-line {
animation: none;
background: hsl(200, 20%, 80%); /* Static gray */
}
}2. Micro-Interactions: Button Press Feedback
<button class="press-button">Click Me</button>
<style>
.press-button {
padding: 12px 24px;
background: hsl(220, 90%, 50%);
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
/* Prepare for transform (creates composite layer) */
transform: translateZ(0);
transition: transform 0.1s ease-out;
}
.press-button:active {
transform: scale(0.95);
}
/* Why this works:
- transform triggers compositor (no layout)
- transition is hardware accelerated
- Responds instantly to :active (no JS event listener delay)
- 0.1s duration feels tactile (shorter = snappier)
*/
</style>3. Loading Spinner (Transform + Opacity)
<div class="spinner"></div>
<style>
.spinner {
width: 40px;
height: 40px;
border: 4px solid rgba(0, 0, 0, 0.1);
border-top-color: hsl(220, 90%, 50%);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Performance characteristics:
- rotate() uses transform (compositor)
- No layout or paint (GPU only)
- linear timing = constant speed
- infinite = no JS needed to loop
*/
/* Bonus: Pause when tab inactive (browser optimization) */
</style>4. Staggered List Entrance
<ul class="staggered-list">
<li style="--index: 0">Item 1</li>
<li style="--index: 1">Item 2</li>
<li style="--index: 2">Item 3</li>
</ul>
<style>
.staggered-list li {
opacity: 0;
transform: translateY(20px);
animation: slideIn 0.4s ease-out forwards;
animation-delay: calc(var(--index) * 0.1s);
}
@keyframes slideIn {
to {
opacity: 1;
transform: translateY(0);
}
}
/* Technique:
- CSS custom properties (--index) control delay
- No JavaScript loop needed
- forwards = maintain final state
- Stagger: 0s, 0.1s, 0.2s delays
*/
</style>The FLIP Technique: Animating the "Unanimatable"
Problem: You can't animate layout properties (width, height, top, left) smoothly with CSS. They trigger layout every frame. But you can animate transform. FLIP lets you fake layout animations using only transform.
How FLIP Works
FLIP = First, Last, Invert, Play
- First: Measure element's current position/size
- Last: Apply layout change (instant), measure new position/size
- Invert: Use transform to move element back to "First" position
- Play: Animate transform back to identity (final position)
Example: Animating Grid Item Expansion
<!-- Grid of cards -->
<div class="grid">
<div class="card" data-id="1">Card 1</div>
<div class="card" data-id="2">Card 2</div>
<div class="card" data-id="3">Card 3</div>
</div>
<script>
function expandCard(card) {
// FIRST: Record initial position
const first = card.getBoundingClientRect();
// LAST: Apply expanded class (instant layout change)
card.classList.add('expanded');
const last = card.getBoundingClientRect();
// INVERT: Calculate difference and apply transform
const deltaX = first.left - last.left;
const deltaY = first.top - last.top;
const deltaW = first.width / last.width;
const deltaH = first.height / last.height;
card.style.transform = `
translate(${deltaX}px, ${deltaY}px)
scale(${deltaW}, ${deltaH})
`;
card.style.transition = 'none'; // Instant invert
// PLAY: Animate to final position
requestAnimationFrame(() => {
card.style.transition = 'transform 0.3s ease-out';
card.style.transform = 'none'; // Identity transform
});
}
</script>
<style>
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.card {
padding: 20px;
background: white;
border-radius: 8px;
cursor: pointer;
}
.card.expanded {
grid-column: 1 / -1; /* Full width */
height: 300px; /* Taller */
}
/* Result:
- User clicks card
- Card smoothly expands to full width
- Only transform animated (compositor thread)
- No layout thrashing despite grid reflow
*/
</style>FLIP for List Reordering
function animateListReorder(container, newOrder) {
const items = Array.from(container.children);
// FIRST: Record all positions before reorder
const first = items.map(item => ({
item,
rect: item.getBoundingClientRect()
}));
// LAST: Apply new order (instant DOM manipulation)
newOrder.forEach(index => {
container.appendChild(items[index]);
});
const last = items.map(item => ({
item,
rect: item.getBoundingClientRect()
}));
// INVERT + PLAY: Animate each item
first.forEach((f, i) => {
const l = last[i];
const deltaX = f.rect.left - l.rect.left;
const deltaY = f.rect.top - l.rect.top;
// Invert
f.item.style.transform = `translate(${deltaX}px, ${deltaY}px)`;
f.item.style.transition = 'none';
// Play
requestAnimationFrame(() => {
f.item.style.transition = 'transform 0.3s ease-out';
f.item.style.transform = 'none';
});
});
}
/* Use case: Drag-and-drop, sort by filters, shuffle
Performance: Only transform animated (compositor)
No layout thrashing despite DOM reordering
*/When JavaScript Animations Are Necessary
CSS animations can't handle:
- Physics-based animations: Spring dynamics, momentum scrolling
- Complex choreography: Synchronized multi-element sequences
- Dynamic values: Animating to a value calculated at runtime
- Input-driven: Following mouse/touch coordinates
Use Web Animations API (Best of Both Worlds)
The Web Animations API lets you write JavaScript animations that run on the compositor thread.
// Web Animations API - runs on compositor!
element.animate([
{ transform: 'translateX(0)', opacity: 1 },
{ transform: 'translateX(100px)', opacity: 0 }
], {
duration: 500,
easing: 'ease-out',
fill: 'forwards'
});
/* Advantages over CSS:
- Control from JavaScript (play, pause, reverse, cancel)
- Promise-based (await animation.finished)
- Dynamic keyframes (calculated at runtime)
Advantages over requestAnimationFrame:
- Runs on compositor (no main thread blocking)
- Optimized for transform/opacity
- Automatic frame rate management
*/When to Use requestAnimationFrame
// Only use rAF for custom timing/physics
let velocity = 0;
let position = 0;
function animate() {
// Custom spring physics
const target = mouseX;
const spring = (target - position) * 0.1;
velocity += spring;
velocity *= 0.9; // Damping
position += velocity;
element.style.transform = `translateX(${position}px)`;
if (Math.abs(velocity) > 0.1) {
requestAnimationFrame(animate);
}
}
/* Use rAF only when:
- Implementing custom physics
- Synchronizing with external state (canvas, WebGL)
- Fine-grained frame-by-frame control needed
*/Production Checklist
- Default to CSS animations: Use
transitionand@keyframesfor UI micro-interactions. - Animate only transform/opacity: These properties are compositor-safe. Avoid animating width, height, top, left.
- Use FLIP for layout animations: When you must animate layout changes, use FLIP to convert to transform.
- Batch DOM reads/writes: If using rAF, never interleave reads and writes. Read all, then write all.
- Respect prefers-reduced-motion: Disable/reduce animations for vestibular sensitivity.
- Consider Web Animations API: For JavaScript-controlled animations that need compositor thread performance.
- Test with Performance Monitor: Chrome DevTools → Performance → Record. Look for Layout Shift warnings.
- Monitor INP in production: Use Real User Monitoring to catch layout thrashing in the wild.
- Use will-change sparingly:
will-change: transformcreates composite layers, but overuse causes memory bloat.
The Modern Animation Stack
<!-- CSS for simple state transitions -->
<button class="action-button">
Submit
</button>
<style>
.action-button {
transition: transform 0.2s, opacity 0.2s;
}
.action-button:active {
transform: scale(0.95);
}
.action-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
<!-- Web Animations API for complex JavaScript-driven animations -->
<script>
async function submitForm(button) {
// Disable and animate
button.disabled = true;
const pulse = button.animate([
{ transform: 'scale(1)' },
{ transform: 'scale(1.05)' },
{ transform: 'scale(1)' }
], {
duration: 300,
iterations: 3
});
// Wait for animation + API call
await Promise.all([
pulse.finished,
fetch('/api/submit', { method: 'POST' })
]);
button.textContent = 'Success!';
}
</script>
<!-- FLIP for layout animations -->
<script>
function expandDetails(summary) {
const details = summary.nextElementSibling;
// FLIP technique for smooth height expansion
const first = details.getBoundingClientRect();
details.classList.add('open');
const last = details.getBoundingClientRect();
const deltaHeight = first.height / last.height;
details.style.transform = `scaleY(${deltaHeight})`;
details.style.transformOrigin = 'top';
requestAnimationFrame(() => {
details.style.transition = 'transform 0.3s ease-out';
details.style.transform = 'scaleY(1)';
});
}
</script>
/* Architecture:
- CSS: 80% of animations (buttons, hovers, fades)
- Web Animations API: 15% (JavaScript-controlled, compositor-safe)
- FLIP: 4% (layout changes)
- requestAnimationFrame: 1% (custom physics only)
Result: Smooth 60fps animations, minimal main thread blocking
*/JavaScript animations aren't inherently bad—but they require discipline. One misplaced DOM read, one animated width property, and you're blocking the main thread. CSS animations run on the compositor by default, avoiding these pitfalls. When JavaScript is necessary, use Web Animations API or FLIP to maintain compositor-thread performance. The browser has optimized CSS animations for 15+ years. Use that work to your advantage.
Advertisement
Explore these curated resources to deepen your understanding
Official Documentation
MDN: CSS and JavaScript Animation Performance
Comprehensive guide to animation performance optimization
Web.dev: Avoid Large, Complex Layouts and Layout Thrashing
Official Google guidance on preventing forced synchronous layouts (updated May 2025)
MDN: Web Animations API
JavaScript animations that run on the compositor thread
Web.dev: CSS vs JavaScript Animations
When to use CSS vs JavaScript for animations
Tools & Utilities
Further Reading
FLIP Your Animations - Paul Lewis
Original article introducing the FLIP technique (2015)
Animating the Unanimatable - Josh W. Comeau
FLIP technique for React list animations with practical examples
Building Skeleton Screens with CSS Custom Properties
Pure CSS skeleton loader implementation techniques
Forced Reflow: How to Prevent Layout Thrashing (July 2025)
Recent guide on layout thrashing impact on Core Web Vitals
Related Insights
Explore related edge cases and patterns
Advertisement