Animation Performance & Optimization
Why Animation Performance Matters
A beautiful animation that stutters, freezes, or causes the page to feel sluggish is worse than no animation at all. Users perceive jank -- visible stuttering or frame drops -- as a sign of a broken or low-quality application. To create smooth animations that run at 60 frames per second (the target for most displays), you need to understand how the browser renders content and which CSS properties are cheap or expensive to animate.
This lesson dives deep into the browser rendering pipeline, explains why some properties animate smoothly while others cause performance problems, and gives you practical tools and techniques to diagnose and fix animation performance issues. By the end, you will have a clear mental model of what happens behind the scenes when your CSS animations run, and a checklist for ensuring your animations are always buttery smooth.
The Browser Rendering Pipeline
Every time the browser needs to display something on screen, it goes through a series of steps called the rendering pipeline (also known as the pixel pipeline). Understanding these steps is the key to understanding animation performance. Each step has a cost, and animations that trigger more steps are more expensive.
Step 1: Parse (HTML and CSS)
The browser reads your HTML and builds the DOM (Document Object Model) tree. It also parses your CSS and creates the CSSOM (CSS Object Model). These two trees represent the structure of your document and the styles that apply to each element. This step happens once when the page loads and again whenever the DOM or styles change. For animations, the parsing step is not typically the bottleneck because the DOM structure does not change during a CSS animation.
Step 2: Style (Recalculate Styles)
The browser combines the DOM and CSSOM to figure out which CSS rules apply to each element and what the final computed values are. This is called style recalculation. When an animation changes a CSS property, the browser must recalculate the styles for the affected element and potentially its descendants. The cost of this step depends on how many elements are affected by the style change and how complex your CSS selectors are.
Step 3: Layout (Reflow)
Once the browser knows each element's styles, it calculates the geometry: position, size, and how elements relate to each other spatially. This step is called layout or reflow. Layout is expensive because changing one element's size or position can cascade and affect the layout of many other elements. If your animation changes properties like width, height, top, left, margin, or padding, the browser must recalculate layout on every frame of the animation.
Step 4: Paint
After layout, the browser fills in the pixels. It draws text, colors, images, borders, shadows -- every visual part of each element. Paint operations fill pixel buffers (bitmaps) that represent the visual content of each layer. Painting is moderately expensive and is triggered when you change visual properties that do not affect layout, such as background-color, color, box-shadow, or border-color.
Step 5: Composite
Finally, the browser takes the painted layers and combines them in the correct order to produce the final image on screen. This is called compositing. The compositor can handle certain operations very efficiently because it works with already-painted layers -- it just needs to move, rotate, scale, or change the opacity of these layers. This is why transform and opacity are the cheapest properties to animate: they only trigger the composite step, skipping layout and paint entirely.
The Rendering Pipeline Steps
/* The full pipeline (most expensive):
Parse → Style → Layout → Paint → Composite
Properties that trigger LAYOUT (most expensive):
width, height, top, left, right, bottom,
margin, padding, border-width, display,
position, float, font-size, font-weight,
line-height, text-align, overflow, flex, grid
Properties that trigger PAINT (moderate cost):
background-color, color, box-shadow, border-color,
border-style, border-radius, outline, visibility,
text-decoration, background-image
Properties that trigger COMPOSITE ONLY (cheapest):
transform, opacity, filter (in some browsers),
will-change, perspective */
Compositor-Only Properties: transform and opacity
The single most important rule for animation performance is this: whenever possible, only animate transform and opacity. These two properties are special because the browser can handle them entirely on the compositor thread, which runs separately from the main thread. This means your animations can remain smooth even if the main thread is busy with JavaScript execution, DOM manipulation, or other tasks.
Why transform Is So Powerful
The transform property can replace most layout-triggering animations. Instead of animating top or left to move an element, use translateX() or translateY(). Instead of animating width or height to resize, use scale(). The visual result is often identical, but the performance difference is enormous.
Bad vs Good: Moving an Element
/* BAD: Animating top/left triggers layout on every frame */
@keyframes moveBoxBad {
from {
top: 0;
left: 0;
}
to {
top: 200px;
left: 300px;
}
}
.box-bad {
position: absolute;
animation: moveBoxBad 1s ease-out forwards;
/* Triggers: Style → Layout → Paint → Composite (every frame) */
}
/* GOOD: Animating transform only triggers composite */
@keyframes moveBoxGood {
from {
transform: translate(0, 0);
}
to {
transform: translate(300px, 200px);
}
}
.box-good {
animation: moveBoxGood 1s ease-out forwards;
/* Triggers: Composite only (every frame) -- much faster! */
}
Bad vs Good: Resizing an Element
/* BAD: Animating width/height triggers layout */
@keyframes growBad {
from {
width: 100px;
height: 100px;
}
to {
width: 200px;
height: 200px;
}
}
.grow-bad {
animation: growBad 0.5s ease-out;
/* Layout recalculates every frame, potentially affecting siblings */
}
/* GOOD: Animating scale only triggers composite */
@keyframes growGood {
from {
transform: scale(1);
}
to {
transform: scale(2);
}
}
.grow-good {
animation: growGood 0.5s ease-out;
/* No layout recalculation -- just the compositor at work */
}
transform: scale() is much more performant than animating width/height, they do not produce identical results. Scaling stretches the rendered content (including text and borders), while changing width/height causes the browser to re-layout and re-render at the new size. For most UI animations like hover effects and entrance animations, the difference is imperceptible. But for text-heavy elements or elements with complex borders, you may notice a visual difference.The Power of opacity
Opacity changes are handled entirely by the compositor because they only affect how layers are blended together. Fading elements in and out is one of the cheapest animations you can do. If you need to hide and show elements with animation, always prefer animating opacity over visibility or display.
Efficient Fade Animations
/* GOOD: Opacity is compositor-only */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
/* Combine transform and opacity for the best performance */
@keyframes slideInOptimized {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* This animation only triggers compositing -- maximum performance */
.optimized-entrance {
animation: slideInOptimized 0.4s ease-out both;
}
Why Animating Layout Properties Is Expensive
When you animate a property that triggers layout (like width, height, margin, top, or left), the browser must perform the following work on every single animation frame (ideally 60 times per second):
- Recalculate styles for the element and potentially its ancestors and descendants.
- Run layout to determine the new geometry of the element and everything it affects. A change in one element's width can shift siblings, change the parent's height, trigger scrollbar recalculations, and cascade through the entire document.
- Repaint every affected area of the screen.
- Composite the updated layers.
All of this work happens on the main thread, which is also responsible for running JavaScript, handling user input, and processing network responses. If layout calculation takes more than about 16 milliseconds (the budget for one frame at 60 fps), the browser drops a frame and the user sees jank.
Common Layout-Triggering Animations to Avoid
/* AVOID: These all trigger layout on every frame */
/* Animating dimensions */
@keyframes expandWidth {
to { width: 100%; } /* Triggers layout */
}
/* Animating position properties */
@keyframes slideDown {
to { top: 100px; } /* Triggers layout */
}
/* Animating margins */
@keyframes pushDown {
to { margin-top: 50px; } /* Triggers layout, pushes other elements */
}
/* Animating padding */
@keyframes padIn {
to { padding: 30px; } /* Triggers layout, changes content area */
}
/* Animating border-width */
@keyframes borderGrow {
to { border-width: 10px; } /* Triggers layout */
}
/* BETTER ALTERNATIVES using transform: */
/* expandWidth → use scaleX() */
/* slideDown → use translateY() */
/* pushDown → use translateY() (though this won't push siblings) */
/* padIn → pre-size the element and use scale or opacity */
/* borderGrow → use box-shadow or outline instead */
The will-change Property
The will-change property is a hint to the browser that an element's specific properties are likely to change in the near future. When you declare will-change, the browser can set up optimizations in advance -- such as promoting the element to its own compositor layer or pre-allocating memory for the animation. This can eliminate the setup cost that would otherwise occur when the animation starts.
Using will-change Correctly
/* Tell the browser this element will be animated */
.card {
will-change: transform, opacity;
}
/* Better: apply will-change only when needed */
.card-container:hover .card {
will-change: transform;
}
.card-container:hover .card {
animation: slideUp 0.3s ease-out forwards;
}
/* Or apply will-change just before the animation starts */
.card.about-to-animate {
will-change: transform, opacity;
}
.card.animating {
animation: slideInOptimized 0.5s ease-out both;
}
.card.animation-done {
will-change: auto; /* Remove the hint after animation */
}
will-change to every element or use it as a blanket performance fix. Each will-change declaration causes the browser to allocate additional memory and create a new compositor layer. Too many layers consume GPU memory, can actually degrade performance, and may cause visual artifacts. Use will-change only on elements you know will animate, and ideally remove it after the animation completes.When to Use will-change
The ideal usage pattern for will-change is to apply it slightly before an animation begins and remove it after the animation ends. This gives the browser time to set up the optimization without permanently consuming resources.
Practical will-change Patterns
/* Pattern 1: Apply on hover of parent */
.gallery-item:hover .thumbnail {
will-change: transform;
transform: scale(1.1);
transition: transform 0.3s ease;
}
/* Pattern 2: Apply with JavaScript before animation */
JavaScript Pattern for will-change
// Apply will-change before animation
function animateElement(el) {
el.style.willChange = 'transform, opacity';
// Small delay to let browser set up optimization
requestAnimationFrame(() => {
el.classList.add('animate-in');
});
// Remove will-change after animation completes
el.addEventListener('animationend', () => {
el.style.willChange = 'auto';
}, { once: true });
}
Anti-Patterns: Overusing will-change
/* BAD: Applying will-change to everything */
* {
will-change: transform, opacity;
/* This creates layers for EVERY element -- massive memory waste */
}
/* BAD: Too many properties */
.element {
will-change: transform, opacity, top, left, width, height,
background-color, box-shadow, border-radius;
/* Over-hinting defeats the purpose */
}
/* BAD: Permanent will-change on elements that rarely animate */
.static-header {
will-change: transform;
/* This header never actually animates -- wasted resources */
}
/* GOOD: Targeted and temporary */
.modal.opening {
will-change: transform, opacity;
}
.modal.open {
will-change: auto;
}
The contain Property
The contain property tells the browser that an element and its contents are independent from the rest of the document in specific ways. This allows the browser to optimize by limiting the scope of rendering calculations. When you animate elements inside a contained area, the browser knows the changes cannot affect elements outside that area, so it can skip recalculating layout or paint for the rest of the page.
Using the contain Property
/* Layout containment: element's internal layout
does not affect the rest of the page */
.widget {
contain: layout;
}
/* Paint containment: element's visual content
does not paint outside its bounds */
.animated-card {
contain: paint;
}
/* Size containment: element's size is determined
independently of its children */
.fixed-panel {
contain: size;
width: 300px;
height: 200px;
}
/* Strict containment: layout + paint + size */
.fully-contained {
contain: strict;
width: 100%;
height: 400px;
}
/* Content containment: layout + paint (most commonly useful) */
.contained-section {
contain: content;
}
/* Practical: contain an animation area to limit its performance impact */
.animation-container {
contain: content;
/* Now layout/paint changes inside this element
won't trigger recalculation outside it */
}
.animation-container .animated-item {
animation: complexAnimation 2s ease infinite;
}
contain: content value (which combines layout and paint containment) is the safest and most commonly useful option. It does not require you to set explicit dimensions (unlike contain: size) but still provides significant optimization benefits by isolating the element's rendering from the rest of the page.Reducing Layout Thrashing
Layout thrashing occurs when JavaScript repeatedly reads layout properties and then writes style changes in an interleaved fashion, forcing the browser to recalculate layout multiple times synchronously. While this is primarily a JavaScript concern, it directly impacts animation performance because it can starve the main thread of time needed to process CSS animations.
Layout Thrashing: The Problem
/* JavaScript that causes layout thrashing */
// BAD: Read-write-read-write pattern forces multiple layout calculations
function updateElements(elements) {
elements.forEach(el => {
const height = el.offsetHeight; // Read (forces layout)
el.style.height = (height * 2) + 'px'; // Write (invalidates layout)
// Next iteration: read forces layout again!
});
}
// GOOD: Batch reads, then batch writes
function updateElementsBetter(elements) {
// First pass: read all values
const heights = elements.map(el => el.offsetHeight);
// Second pass: write all values
elements.forEach((el, i) => {
el.style.height = (heights[i] * 2) + 'px';
});
// Only one layout recalculation!
}
Properties That Force Layout When Read
/* Reading any of these properties forces the browser
to synchronously calculate layout (if it's dirty):
Element properties:
- offsetTop, offsetLeft, offsetWidth, offsetHeight
- scrollTop, scrollLeft, scrollWidth, scrollHeight
- clientTop, clientLeft, clientWidth, clientHeight
- getComputedStyle() (for layout-related properties)
- getBoundingClientRect()
Window properties:
- window.scrollX, window.scrollY
- window.innerWidth, window.innerHeight
Best practice: Avoid reading these properties inside
animation loops or rapid event handlers. If you must
read them, cache the values and read them all at once
before making any style changes. */
requestAnimationFrame vs CSS Animations
When should you use CSS animations and when should you use JavaScript with requestAnimationFrame? Each approach has strengths and the right choice depends on your specific use case.
CSS Animations: Strengths
- Compositor-optimized: CSS animations of
transformandopacitycan run entirely on the compositor thread, independent of the main thread. This means they keep running smoothly even when JavaScript is busy. - Declarative: You describe what should happen and the browser handles the how. The browser can apply its own optimizations.
- No JavaScript required: Fewer bytes to download and parse. No risk of JavaScript errors breaking the animation.
- GPU-accelerated by default: The browser automatically promotes animated elements to GPU layers when using transform and opacity.
requestAnimationFrame: Strengths
- Dynamic control: You can change animation parameters based on user input, physics calculations, or real-time data.
- Complex choreography: Coordinating many elements with interdependent timing is easier in JavaScript.
- Canvas and WebGL: For canvas-based or 3D animations, JavaScript is the only option.
- Precise timing: You have access to the exact timestamp of each frame for physics-based animations.
When to Choose Which Approach
/* USE CSS ANIMATIONS for:
- Simple state transitions (hover effects, page entrances)
- Loading spinners and looping animations
- Predefined animation sequences
- Animations that should run during JavaScript-heavy operations
- Scroll-triggered reveal animations (with Intersection Observer)
*/
/* USE requestAnimationFrame for:
- Animations that depend on user input (mouse position, scroll)
- Physics-based animations (spring, gravity, momentum)
- Canvas/WebGL rendering
- Animations that need to be coordinated with JavaScript logic
- Game loops
*/
/* Example: CSS animation for a simple entrance */
.card {
animation: fadeInUp 0.5s ease-out both;
}
/* Example: requestAnimationFrame for cursor-following */
requestAnimationFrame Example
// Cursor-following animation (not possible with CSS alone)
const follower = document.querySelector('.cursor-follower');
let mouseX = 0, mouseY = 0;
let followerX = 0, followerY = 0;
document.addEventListener('mousemove', (e) => {
mouseX = e.clientX;
mouseY = e.clientY;
});
function animate() {
// Smooth interpolation toward mouse position
followerX += (mouseX - followerX) * 0.1;
followerY += (mouseY - followerY) * 0.1;
// Use transform for performance!
follower.style.transform =
`translate(${followerX}px, ${followerY}px)`;
requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
Hardware Acceleration and GPU Layers
When an element is promoted to its own compositor layer, its rendering is handled by the GPU rather than the CPU. The GPU excels at operations like moving, rotating, scaling, and adjusting the opacity of textures (bitmaps) -- which is exactly what happens during transform and opacity animations. This is why these animations can run at 60 fps even on modest hardware.
What Causes Layer Promotion
The browser automatically creates new compositor layers for elements in several situations:
- Elements with
will-change: transformorwill-change: opacity - Elements actively being animated with
transformoropacity - Elements with 3D transforms (
translate3d,rotate3d,perspective) - Elements with certain CSS filters
<video>and<canvas>elements- Elements that overlap other composited layers (implicit promotion)
Triggering GPU Acceleration
/* The browser automatically promotes these to GPU layers */
/* Active CSS animation on transform */
.animated {
animation: slideIn 0.5s ease-out;
}
/* will-change hint */
.about-to-animate {
will-change: transform;
}
/* 3D transform (classic hack -- now prefer will-change) */
.gpu-layer {
transform: translateZ(0);
/* or */
transform: translate3d(0, 0, 0);
/* These force a GPU layer, but will-change is the modern approach */
}
/* Note: the translateZ(0) hack still works and is widely used,
but will-change is more semantically correct and gives the
browser more information about what to optimize. */
Using DevTools to Diagnose Performance
Modern browser DevTools provide powerful instruments for diagnosing animation performance issues. Learning to use these tools is essential for any developer who works with animations.
The Performance Tab
The Performance tab (in Chrome DevTools) lets you record a timeline of everything the browser does while your animation runs. You can see exactly how long each frame takes, which phases of the rendering pipeline are running, and whether any frames exceed the 16ms budget.
How to Use the Performance Tab
/* Steps to profile an animation:
1. Open Chrome DevTools (F12 or Cmd+Opt+I)
2. Go to the Performance tab
3. Click the Record button (circle icon)
4. Trigger your animation on the page
5. Wait for the animation to complete
6. Click Stop to end the recording
What to look for in the recording:
- Green bars: Paint operations
- Purple bars: Layout operations
- Yellow bars: JavaScript execution
- Frame rate chart: should be steady at ~60 fps
- Red triangles: indicate dropped frames (jank)
If you see lots of purple (Layout) bars during animation:
→ You are animating layout-triggering properties
→ Switch to transform and opacity
If you see lots of green (Paint) bars during animation:
→ You are animating paint-triggering properties
→ Consider if transform can achieve the same effect
If the frame rate dips below 60 fps:
→ Check which rendering phases are taking too long
→ Reduce the complexity of the animated properties */
The Layers Panel
The Layers panel shows you all compositor layers on the page, their sizes, memory consumption, and why they were created. This is invaluable for understanding which elements are GPU-accelerated and whether you have too many layers.
Using the Layers Panel
/* Steps to view compositor layers:
Chrome:
1. Open DevTools → More tools → Layers
2. Or: Open DevTools → Rendering tab → check "Layer borders"
(shows colored borders around compositor layers on the page)
What to check:
- How many layers exist (fewer is generally better)
- Total memory consumed by all layers
- Reason for each layer's creation (will-change, transform, etc.)
- Whether unexpected elements have been promoted to layers
Firefox:
1. Open DevTools → Settings → enable "Toggle paint flashing"
(highlights areas being repainted in green)
The paint flashing overlay is especially useful:
- Green flashes = areas being repainted
- During an optimized animation, you should see NO green flashes
- If you see green flashes on every frame, the animation
is triggering paint and should be optimized */
Rendering Panel Overlays
Chrome DevTools Rendering Overlays
/* Access: DevTools → three-dot menu → More tools → Rendering
Useful overlays:
1. Paint flashing:
Shows green rectangles over areas being repainted.
During a transform/opacity animation, nothing should flash.
2. Layout Shift Regions:
Shows blue rectangles over areas experiencing layout shifts.
Useful for detecting animations that cause layout instability.
3. FPS meter:
Shows a real-time FPS counter and frame timing graph.
Target: steady 60 fps line.
4. Scrolling performance issues:
Highlights elements that slow down scrolling.
Relevant for scroll-linked animations.
5. Core Web Vitals:
Monitors LCP, FID, and CLS in real-time.
CLS (Cumulative Layout Shift) is directly affected
by layout-triggering animations. */
Jank-Free Animation Checklist
Use this checklist every time you create an animation to ensure it runs smoothly across all devices. Following these rules will prevent the vast majority of animation performance issues.
The Complete Jank-Free Checklist
/* 1. ANIMATE ONLY TRANSFORM AND OPACITY
These are the only properties that run on the compositor thread.
✓ transform: translate(), scale(), rotate(), skew()
✓ opacity
✗ width, height, top, left, margin, padding, border-width */
/* 2. USE will-change SPARINGLY
Apply it before animation starts, remove after it ends.
Never apply it to more than a handful of elements. */
.about-to-animate { will-change: transform, opacity; }
.done-animating { will-change: auto; }
/* 3. CONTAIN YOUR ANIMATIONS
Use the contain property to limit rendering scope. */
.animation-area {
contain: content;
}
/* 4. AVOID ANIMATING DURING SCROLL
Scroll handlers + animation = jank.
Use Intersection Observer instead of scroll events.
Use CSS scroll-driven animations where supported. */
/* 5. KEEP LAYERS MINIMAL
Every GPU layer costs memory.
Do not promote elements to layers unnecessarily.
Check Layers panel to verify layer count. */
/* 6. TEST ON REAL DEVICES
A smooth animation on your developer machine might
stutter on a mid-range phone. Always test on:
- A mid-range Android phone
- An older tablet
- A laptop with integrated graphics */
/* 7. RESPECT REDUCED MOTION PREFERENCES */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}
/* 8. LIMIT SIMULTANEOUS ANIMATIONS
More concurrent animations = more work per frame.
Stagger animations instead of running all at once.
Keep the total number of animated elements under 20. */
/* 9. USE APPROPRIATE DURATIONS
Enter: 200-500ms (users expect quick responses)
Exit: 150-300ms (should feel slightly faster than enter)
Looping/ambient: 1000-3000ms (should feel relaxed) */
/* 10. AVOID LAYOUT THRASHING IN JS
Batch DOM reads before DOM writes.
Use requestAnimationFrame for JS-driven animations.
Never read layout properties inside animation loops. */
Practical Optimization Examples
Let us look at real-world scenarios where animation performance problems commonly occur and how to fix them.
Optimizing a Card Hover Effect
Before and After Optimization
/* BEFORE: Multiple layout and paint triggers */
.card {
transition: all 0.3s ease;
/* "all" transitions EVERYTHING that changes, including
layout-triggering properties */
}
.card:hover {
margin-top: -10px; /* Layout trigger */
box-shadow: 0 10px 30px rgba(0,0,0,0.2); /* Paint trigger */
border-color: #3498db; /* Paint trigger */
padding: 22px; /* Layout trigger */
}
/* AFTER: Compositor-only with minimal paint */
.card {
transition: transform 0.3s ease, box-shadow 0.3s ease;
/* Only transition specific properties */
}
.card:hover {
transform: translateY(-10px); /* Compositor only */
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
/* box-shadow triggers paint, but it is unavoidable here.
At least we eliminated the layout triggers. */
}
Optimizing a Page Entrance Sequence
Optimized Staggered Entrance
/* Use only transform and opacity for entrance animations */
@keyframes optimizedEntrance {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Stagger with delays, limit total animated elements */
.section-content > * {
opacity: 0;
animation: optimizedEntrance 0.4s ease-out both;
}
.section-content > *:nth-child(1) { animation-delay: 0.05s; }
.section-content > *:nth-child(2) { animation-delay: 0.10s; }
.section-content > *:nth-child(3) { animation-delay: 0.15s; }
.section-content > *:nth-child(4) { animation-delay: 0.20s; }
.section-content > *:nth-child(5) { animation-delay: 0.25s; }
/* Stop here -- animating more than ~10 elements simultaneously
can cause frame drops on mobile devices */
/* Use contain on the parent to limit rendering scope */
.section-content {
contain: content;
}
Optimizing a Loading Skeleton
Performance-Optimized Shimmer Effect
/* BAD: Animating background-position triggers paint */
@keyframes shimmerBad {
to { background-position: 200% 0; }
/* This works but repaints on every frame */
}
/* BETTER: Use a pseudo-element with transform */
@keyframes shimmerGood {
from { transform: translateX(-100%); }
to { transform: translateX(100%); }
}
.skeleton-optimized {
position: relative;
overflow: hidden;
background: #e0e0e0;
}
.skeleton-optimized::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.4),
transparent
);
animation: shimmerGood 1.5s ease-in-out infinite;
/* Transform-based: compositor only, no repaint! */
}
Optimizing Scroll-Triggered Animations
Using Intersection Observer Instead of Scroll Events
/* CSS: define the animation */
@keyframes revealOnScroll {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.scroll-reveal {
opacity: 0; /* Hidden by default */
}
.scroll-reveal.visible {
animation: revealOnScroll 0.6s ease-out both;
}
JavaScript: Intersection Observer
// GOOD: Intersection Observer (off main thread, no scroll jank)
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
observer.unobserve(entry.target);
}
});
}, {
threshold: 0.1,
rootMargin: '0px 0px -50px 0px'
});
document.querySelectorAll('.scroll-reveal').forEach(el => {
observer.observe(el);
});
// BAD: Scroll event listener (fires on every scroll, blocks main thread)
// window.addEventListener('scroll', () => {
// elements.forEach(el => {
// if (isInViewport(el)) el.classList.add('visible');
// });
// });
Measuring Animation Performance Programmatically
Beyond visual inspection with DevTools, you can measure animation performance programmatically using the Performance API and PerformanceObserver.
Monitoring Frame Rate with JavaScript
// Simple FPS counter
let frameCount = 0;
let lastTime = performance.now();
function measureFPS() {
frameCount++;
const currentTime = performance.now();
if (currentTime - lastTime >= 1000) {
console.log(`FPS: ${frameCount}`);
frameCount = 0;
lastTime = currentTime;
}
requestAnimationFrame(measureFPS);
}
requestAnimationFrame(measureFPS);
// Monitor long frames (potential jank)
const longFrameObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 16.67) {
console.warn(
`Long frame detected: ${entry.duration.toFixed(1)}ms`,
entry
);
}
}
});
longFrameObserver.observe({ type: 'long-animation-frame', buffered: true });
Exercise 1: Optimize a Poorly Performing Animation
Take the following poorly optimized animation and rewrite it to be compositor-friendly. The original animation moves a card up by 20 pixels, increases its width from 300px to 350px, adds padding from 16px to 24px, changes its background color from white to a light blue, adds a box-shadow, and increases the border from 1px to 3px -- all on hover with transition: all 0.3s ease. Rewrite this animation to achieve the same visual effect (or as close as possible) using only transform, opacity, and at most one paint-triggering property (box-shadow). Use will-change appropriately by applying it on hover of the parent container and removing it when the hover ends. Add contain: content to the card's container to limit the rendering scope. Profile both versions in DevTools' Performance tab and compare the number of layout and paint operations per frame. Write down your observations about the difference in frame timing between the two versions.
Exercise 2: Build a Performance-Optimized Animation Dashboard
Create a dashboard page with at least six animated widget cards that reveal themselves as the user scrolls down. Use Intersection Observer to trigger the animations instead of scroll event listeners. Each card should have a staggered entrance animation using only transform and opacity, with delays calculated based on each card's position in the grid. Add a continuously animated background element (such as a slow-rotating gradient or floating particles) that uses only compositor-friendly properties. Apply contain: content to each widget container. Use will-change correctly by adding it via JavaScript just before each card's animation starts and removing it when the animation ends. Add an FPS counter overlay (using the requestAnimationFrame technique from this lesson) that displays the current frame rate. Include a toggle button that switches between an unoptimized version (using width, height, top, left animations) and the optimized version (using transform and opacity) so you can visually compare the performance difference. Add the prefers-reduced-motion media query to disable or simplify all animations for users who prefer reduced motion. Test the page with CPU throttling at 6x in DevTools and verify that the optimized version maintains a steady 60 fps while the unoptimized version drops frames.