Animations Without Jank: Performance Tricks for Motion in Web Apps
#webperf
#frontend
#motion
Introduction
Motion should feel fluid, not chaotic. In web apps, jank often hides in the gaps between frames: layout thrash, forced paints, and expensive JavaScript keeping the main thread busy. The goal of this post is to lay out practical, battle-tested strategies to keep animations smooth while keeping your app responsive and accessible.
Core Principles
- Keep work on the compositor thread whenever possible. Transform and opacity changes can be rasterized by the GPU without triggering layout or paint.
- Avoid layout thrashing. Reading layout properties forces the browser to recalculate styles and reflow the entire tree.
- Measure with intention. Always profile with real user conditions and a representative device set.
- Respect user preferences. If a user enables prefers-reduced-motion, gracefully degrade animations.
CSS vs JS animation
- Prefer CSS transitions and CSS animations for properties that are composited (transform, opacity, filter). They are usually optimized by the browser and run on the compositor.
- Use JavaScript only when you need dynamic control, synchronization with data, or physics-based motion. When doing JS-driven animation, coordinate with requestAnimationFrame and keep per-frame work minimal.
- Use will-change sparingly. It hints the browser about upcoming changes but can exhaust resources if overused.
Techniques to avoid jank
- Animate only composited properties: transform and opacity are your friends. Avoid animating layout-affecting properties like width, height, margin, padding, top, left.
- Use transform translateZ(0) or translate3d(0,0,0) to promote elements to the GPU layer when appropriate, but don’t overdo it.
- Minimize paints. Complex box-shadows, heavy filters, or large paint areas can cause slower frame times.
- Leverage containment. If a component updates independently, use contain: layout, paint, or content to limit the scope of recalculation.
- Batch DOM reads and writes. Read all necessary values first, then apply changes. This reduces layout/reflow thrashing.
- Debounce input-driven motion. For continuous input (dragging, scrolling), throttle or use passive event listeners to avoid blocking the main thread.
- Respect reduced motion. Use media query prefers-reduced-motion to switch to simpler motion or static states.
Practical patterns
-
Pattern: hover or state-based movement
- CSS approach:
- Use transitions on transform and opacity.
- Example:
.card { transition: transform 200ms ease, opacity 200ms ease; will-change: transform, opacity; } .card:hover { transform: translateY(-4px); opacity: 0.98; }
- Why it works: no layout recalculation; the browser can move the card via the compositor.
- CSS approach:
-
Pattern: scroll-linked animation
- Prefer CSS for simple scroll-linked opacity or transform. For more complex behavior, fuse with a lightweight JS RAF loop that updates only a single numeric value and applies it to a transform.
- Example (JS-based, with RAF):
let lastY = 0; let ticking = false; const el = document.querySelector('.header'); function onScroll() { lastY = window.scrollY; if (!ticking) { window.requestAnimationFrame(() => { el.style.transform = `translateY(${lastY * -0.2}px)`; ticking = false; }); ticking = true; } } window.addEventListener('scroll', onScroll, { passive: true }); - Why it works: updates are synced to the display refresh and avoid forcing reflows.
-
Pattern: dimensional animations inside a contained component
- Use contain to isolate layout/paint for heavy widgets:
.widget { contain: layout paint; will-change: transform; transform: translateZ(0); } - Why it works: limits the scope of repaints and layout recalculations when the widget updates.
- Use contain to isolate layout/paint for heavy widgets:
Measuring and profiling
- Use Chrome DevTools Performance tab to capture a representative user scenario. Look for:
- Frame times consistently under 16ms for 60fps.
- Long Tasks: operations that exceed ~50ms in a single frame.
- Recalculate Style, Reflow, and Paint events.
- Use the FPS indicator and painter’s optimizer to identify hotspots.
- When possible, isolate animation to a small subtree so you can measure differences without global noise.
Accessibility considerations
- Always respect prefers-reduced-motion. Provide a non-animated fallback for users who opt out of motion.
- Example:
@media (prefers-reduced-motion: reduce) { .card { transition: none; transform: none; opacity: 1; } }
- Example:
- Ensure focus indicators remain visible during animated transitions and that motion does not interfere with keyboard navigation.
Code examples: quick references
- Avoiding layout thrash:
// Bad: reading layout then writing in a loop const w = el.offsetWidth; el.style.width = w + 10 + 'px'; // Good: batch reads, then writes const w = el.offsetWidth; requestAnimationFrame(() => { el.style.width = w + 10 + 'px'; }); - Smooth loader bar:
.loader { width: 0; height: 4px; background: #06c; transition: width 250ms ease; } // Increase width based on progress, but only on transform/opacity - GPU-accelerated animation snippet:
.floating-icon { transform: translate3d(0, 0, 0); animation: float 4s ease-in-out infinite; } @keyframes float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-8px); } }
Conclusion
Butter smooth motion is less about fancy effects and more about disciplined engineering: keep work off the main thread, rely on compositor-friendly properties, measure under realistic conditions, and respect users’ motion preferences. By combining CSS for straightforward animations with small, well-timed JavaScript updates when necessary, you can deliver motion that enhances user experience without introducing jank.