Color Mode

Animation Performance and Best Practices

A well-crafted animation that runs at 60 frames per second feels effortless. The same animation implemented with the wrong properties can drop to 20 frames per second, cause visible stuttering, and make an otherwise polished interface feel broken. The difference is almost never the animation itself; it is which CSS properties are being animated and how much work the browser has to do to produce each frame.

This reading covers three interconnected topics: which properties the browser can animate efficiently and which ones it cannot, how to identify and fix performance problems using DevTools, and the practical rules that make animations feel intentional and respectful to the people using them.

The Browser Rendering Pipeline

To understand why some animations are cheap and others are expensive, you need to understand what the browser does to produce a single frame on screen. Every frame goes through some or all of these stages:

When you animate a property, the browser must repeat the affected stages on every frame. Triggering layout every frame means the browser recalculates the position and size of potentially hundreds of elements sixty times per second. Triggering only the composite stage means the browser does almost nothing: it just reorders or transforms layers that have already been painted, a task that is handled by the GPU rather than the CPU.

Properties That Only Trigger Compositing

Two properties have special status in every modern browser: transform and opacity. When these are the only properties being animated, the browser can hand the entire animation off to the GPU compositor thread, completely bypassing layout and paint. The main thread (which handles JavaScript execution, style calculation, and layout) is not involved at all. This means the animation continues smoothly even when JavaScript is busy, because the two operations are happening on separate threads.


        /* These are the two safe properties - animate these freely */
        .card {
            animation: enter 300ms ease-out both;
        }

        @keyframes enter {
            from {
                opacity: 0;
                transform: translateY(1rem);
            }
            to {
                opacity: 1;
                transform: translateY(0);
            }
        }
    

transform covers movement (translate), rotation (rotate), scaling (scale), and skewing (skew). If you need an element to appear to move, grow, or spin, transform is always the right tool. opacity covers fades. Between the two, you can express the vast majority of UI animation patterns without touching layout or paint at all.

Properties That Trigger Layout

Animating any property that affects the size or position of an element in document flow triggers a full layout recalculation on every frame. This includes width, height, margin, padding, top, left, right, bottom, font-size, and many others. The browser must recalculate the position of every element that could be affected by the change before it can paint and composite. On a page with hundreds of elements, this can easily consume more than the 16ms budget available for a 60fps frame, causing dropped frames and visible jank.


        /* WRONG - animating width triggers layout every frame */
        @keyframes expand {
            from { width: 0; }
            to { width: 300px; }
        }

        /* CORRECT - animating scaleX achieves the same visual result
           without triggering layout */
        @keyframes expand {
            from { transform: scaleX(0); }
            to { transform: scaleX(1); }
        }
    

The substitution is not always exact: scaling from a transform origin affects the element differently than changing its actual width. In most UI contexts, however,, the visual result is close enough that users cannot tell the difference, and the performance improvement is significant.

Properties That Trigger Paint

Properties that change how pixels are drawn without affecting layout fall into the paint tier. This includes color, background-color, border-color, box-shadow, and filter. Paint is cheaper than layout but more expensive than compositing. Animating background-color on a single small button is fine. Animating box-shadow or filter: blur() on a large element that covers most of the viewport is a different matter; the browser must repaint a large area on every frame, which is expensive.

The practical rule: animate transform and opacity for all motion and entrance/exit effects. Use paint-triggering properties only for color transitions on small elements where the cost is negligible.

will-change

The will-change property is a hint to the browser that a specific property is about to be animated, giving it permission to promote the element to its own compositor layer in advance. This can eliminate a brief jank at the start of an animation caused by the browser creating a new layer mid-animation.


        .modal {
            will-change: transform, opacity;
        }
    

Use will-change sparingly and precisely. Every element promoted to its own layer consumes additional GPU memory. Applying will-change: transform to every element on the page wastes memory and can actually degrade performance rather than improve it. The correct pattern is to apply it only to elements that genuinely have a jank problem at animation start, and only for the specific properties being animated. If you are not measuring a real problem, do not add will-change. If a site runs smoothly without it, it does not need it.

will-change Is Not a General Performance Boost

A common mistake is to apply will-change: transform to elements as a preemptive optimization. This forces the browser to allocate compositor layers for those elements permanently, increasing memory usage with no benefit. Only use it when you have identified an actual jank problem at animation start.

Identifying and Fixing Jank

Jank is the term for visible stuttering or irregularity in an animation. It happens when the browser misses its frame deadline, producing a frame late or skipping one entirely. At 60fps, each frame has approximately 16.7ms to complete all rendering work. Triggering layout or expensive paint operations during an animation is the most common way to blow that budget.

Measuring with DevTools

Chrome and Firefox DevTools both include performance profilers that show exactly what the browser is doing during an animation. In Chrome, open DevTools and go to the Performance panel. Click Record, interact with the animation, then stop recording. The resulting flame chart shows every rendering task broken down by stage: scripting, rendering (which includes layout), and painting. Frames that exceed 16ms appear as red bars in the frame timeline at the top.

The Rendering panel in Chrome (accessed via the three-dot menu in DevTools) includes two tools that are useful during development: Paint Flashing, which highlights regions of the page that are being repainted on each frame in green, and Layer Borders, which outlines compositor layers in orange. If Paint Flashing shows large portions of the page repainting during an animation that should only be compositing, you have a paint problem. If no layers appear where you expect them, will-change may be worth investigating.

Layout Thrashing from JavaScript

When JavaScript is involved in an animation, a different class of performance problem can emerge: layout thrashing. This happens when JavaScript alternately reads and writes layout properties within the same frame, forcing the browser to perform layout multiple times.


        /* WRONG - forces layout twice per element per frame */
        elements.forEach((el) => {
            const height = el.offsetHeight; /* READ - forces layout */
            el.style.height = height + 10 + 'px'; /* WRITE - invalidates layout */
        });

        /* CORRECT - batch reads first, then batch writes */
        const heights = Array.from(elements).map((el) => el.offsetHeight); /* all READs */
        elements.forEach((el, i) => {
            el.style.height = heights[i] + 10 + 'px'; /* all WRITEs */
        });
    

Reading a layout property like offsetHeight, scrollTop, or getBoundingClientRect() after a write forces the browser to perform layout immediately so the returned value is accurate. If you read, write, and read again in a loop, you force multiple layout passes per frame. Batching all reads before all writes avoids this entirely.

requestAnimationFrame

When JavaScript drives an animation directly (updating styles or positions on each frame), always use requestAnimationFrame rather than setTimeout or setInterval. requestAnimationFrame calls your callback at the moment the browser is ready to paint the next frame, synchronizing your updates with the display refresh cycle. setTimeout and setInterval fire on their own schedule, which is not synchronized with the display, causing updates to happen at arbitrary points in the frame cycle and producing inconsistent frame timing.


        /**
         * Animates an element's position using requestAnimationFrame.
         * @param {HTMLElement} el - The element to animate
         */
        const animatePosition = (el) => {
            let start = null;
            const duration = 600;

            const step = (timestamp) => {
                if (!start) start = timestamp;
                const elapsed = timestamp - start;
                const progress = Math.min(elapsed / duration, 1);

                el.style.transform = 'translateX(' + (progress * 200) + 'px)';

                if (progress < 1) {
                    requestAnimationFrame(step);
                }
            };

            requestAnimationFrame(step);
        };
    

In practice, CSS animations and the Web Animations API handle frame synchronization for you, so requestAnimationFrame is most relevant when you are writing custom JavaScript animation loops, such as for canvas-based graphics, physics simulations, or value interpolations that CSS cannot express.

Best Practices

Performance is only one dimension of a well-implemented animation system. An animation can be technically fast while still feeling wrong, being inaccessible, or actively harming the user experience. The following practices address both the technical and human dimensions of animation quality.

Keep Durations Short

UI animations should almost always fall between 100ms and 500ms. Transitions that affect a small element, such as a button hover state or a checkbox toggle, work best at 100 to 200ms. Transitions affecting larger elements, such as a modal sliding into view or a panel expanding, typically work best at 250 to 400ms. Animations over 500ms begin to feel slow, and anything over a second starts to feel like the interface is making the user wait rather than providing feedback.

The instinct to slow down animations because they look better in isolation is common, but animations in a real interface are experienced in sequence. A modal that takes 600ms to open will be triggered dozens of times across a session. What feels elegant the first time becomes frustrating after the tenth.

Limit Simultaneous Animations

Running many animations at the same time increases the composite workload and can make the interface feel chaotic rather than purposeful. As a general rule, try to ensure that at any given moment only a small number of meaningful animations are playing simultaneously. Staggered list animations are an exception because the individual items are related and the sequence communicates structure, but even there, keep the total sequence duration within a reasonable window.

Autoplaying looping animations deserve particular scrutiny. A single looping animation on a page is usually fine. Multiple competing looping animations draw the eye constantly and make the rest of the content harder to read. If an animation does not need to loop to communicate its purpose, it should not loop.

Respect prefers-reduced-motion

This is covered in depth in the previous reading in this series, but it belongs in any list of animation best practices: always implement prefers-reduced-motion support. The safe default pattern (writing reduced styles first and enhancing inside (prefers-reduced-motion: no-preference)) ensures users who need it receive an appropriate experience regardless of what else fails. This is not optional accessibility work. It is the same category of responsibility as color contrast and keyboard navigation.

Test on Lower-End Devices

Development machines are fast. A 6x CPU throttle in Chrome DevTools (available in the Performance panel settings) simulates a mid-range mobile device and will immediately reveal animations that feel smooth on a developer laptop but produce visible jank on real hardware. Test with throttling enabled during development, not as an afterthought before launch. Animations that only work well on high-end hardware are not finished animations.

The Rendering panel's frame rate meter (also in Chrome DevTools) shows a live frames-per-second counter while you interact with the page. If an animation drops below 60fps on a throttled connection, investigate the performance profile before shipping it.

Prefer CSS Animations Over JavaScript for Simple Motion

CSS animations and transitions run on the compositor thread and do not require JavaScript to be executing. A JavaScript-driven animation loop running on the main thread competes with event handlers, DOM updates, and any other script running on the page. For any animation that can be expressed as a CSS transition or @keyframes block, CSS is the more robust choice. Reserve JavaScript animation for cases where the values are not known at authoring time, where the animation needs to respond to runtime conditions, or where you are using the Web Animations API to sequence or control CSS animations programmatically.

The Decision Hierarchy

When implementing motion, work through this hierarchy: CSS transition for simple state changes triggered by a class or pseudo-class, CSS @keyframes for multi-step or looping animations, Web Animations API when you need runtime control over a CSS-expressible animation, and requestAnimationFrame only for genuinely custom animation logic that none of the above can handle.

Animate One Thing at a Time Per Element

Stacking multiple animation declarations on a single element is valid CSS, but when two animations target the same property, the behavior depends on declaration order and can be surprising. More importantly, an element performing five simultaneous animations is harder to reason about, harder to debug, and more likely to produce unintended interactions between the keyframe definitions. Favor composing simple, single-purpose animations over building complex multi-property keyframe blocks, and keep the number of simultaneous animations on any one element to what is genuinely necessary.

Putting It Together

The mental model that ties these topics together is the rendering pipeline. Every animation decision you make either adds work to the pipeline or avoids it. Animating transform and opacity bypasses layout and paint entirely. Animating width or top forces the full pipeline on every frame. will-change moves the layer promotion cost to before the animation starts rather than during it. Batching DOM reads before writes prevents the browser from recalculating layout multiple times within a single frame.

Layered on top of performance is the human dimension: short durations, limited simultaneous motion, reduced motion support, and device testing are not optimizations you add at the end. They are the difference between an animation that feels like a natural part of the interface and one that draws attention to itself for the wrong reasons. A 60fps animation that runs for too long or ignores user preferences is not a well-implemented animation.