CSS Animation Mistakes That Cost Frames

2026-03-29SPUNK13spunk.bet

The frame budget you are spending

At 60fps the browser has about 16.7ms per frame to run JavaScript, recalculate styles, lay out, paint and composite. Realistically you have around 10ms of that, because the browser needs the rest for its own work. On a 120Hz display the budget halves to 8.3ms. Every anti-pattern below is a way of spending that budget on work the compositor could have done for free.

There are three tiers of cost. Changing a property that affects geometry triggers layout, then paint, then composite, for every affected element. Changing a colour or shadow skips layout but still repaints. Changing transform or opacity on an already-layered element touches only the compositor, which runs on its own thread and can keep animating even while the main thread is busy.

Animating width, height, top, left and margin

This is the big one. A slide-in written as left: -300px to left: 0 forces a layout pass on every single frame, and layout is not confined to the element you moved; siblings and ancestors get recalculated too. Write it as transform: translateX(-300px) to translateX(0) and the same visual result costs the compositor a matrix multiply.

Size changes follow the same rule: animate scale() rather than width and height, and accept that scaling scales the children and the border radius with it, which is sometimes what you want and sometimes means restructuring the element. For genuine height animations, such as an accordion, animate grid-template-rows between 0fr and 1fr, or use the newer interpolation to height: auto, instead of measuring pixel heights in JavaScript.

transition: all

It looks harmless and it means "watch every animatable property on this element for changes". The browser then has to check all of them, and you get accidental animations: a class that also changes padding now animates the padding, hovering re-triggers a transition on a property some unrelated rule touched, and a layout-affecting property you never intended to animate becomes a per-frame layout. Name the properties: transition: transform .2s ease, opacity .2s ease;. This also documents intent for the next person.

box-shadow, and the pseudo-element trick

Animating box-shadow on hover repaints a blurred region every frame, and blur is one of the more expensive raster operations, especially at large radii. The fix is to render both shadows up front and cross-fade opacity, which is compositor work:

.card { position: relative; box-shadow: 0 1px 2px rgba(0,0,0,.3); }

.card::after {
  content: ""; position: absolute; inset: 0; border-radius: inherit;
  box-shadow: 0 12px 32px rgba(0,0,0,.45);
  opacity: 0; transition: opacity .2s ease;
  pointer-events: none;
}
.card:hover::after { opacity: 1; }

The same approach works for animating a background gradient or a border colour that has to move fast.

will-change used as a good-luck charm

will-change: transform promotes an element to its own compositor layer, and a layer costs GPU memory proportional to its pixel area. Putting it on a rule that matches every card in a list creates hundreds of layers, and on a mid-range phone that is how you get a page that scrolls worse than before you optimised it. It is a hint applied immediately and held for as long as the declaration is present, so a permanent will-change in a stylesheet is a permanent cost.

Apply it to a small number of elements, apply it shortly before the animation starts (on hover of the parent, or from JavaScript), and remove it when the animation finishes. If you cannot say which specific element needed it, you do not need it.

Reading layout in the middle of an animation

Forced synchronous layout, sometimes called layout thrashing, happens when you write a style and then immediately read a value that depends on layout, such as offsetHeight, getBoundingClientRect(), scrollTop or getComputedStyle(). The browser has to flush pending style and layout work before it can answer. Do that inside a loop over twenty elements and you have forced twenty full layouts in one frame.

Batch instead: read every measurement first, then perform every write. In a scroll handler, prefer IntersectionObserver or ResizeObserver over measuring on every event. Related failure: driving a simple tween with requestAnimationFrame and setting inline styles each tick puts the whole animation on the main thread, where one long task freezes it. A CSS transition or the Web Animations API keeps compositor-only properties running regardless.

Entrance animations you cannot skip, and motion nobody asked for

A 900ms staggered reveal is pleasant on the first visit and a tax on every visit after. Keep interface feedback in the 150 to 250ms range, use up to about 400ms for larger transitions, and make anything longer interruptible so a click during the animation lands immediately. Infinite animations are worse than slow ones: a looping spinner or pulsing badge keeps the compositor awake indefinitely, which shows up as battery drain on mobile and as a fan on a laptop. Pause loops when the element scrolls out of view or when document.hidden is true.

Two correctness details that cause most "why did it snap back" bugs. animation-fill-mode defaults to none, so once the animation ends the element reverts to its base styles unless you set forwards, and an element with opacity: 0 only in a keyframe is fully visible before the animation starts unless you set backwards or give it that base style. And honour prefers-reduced-motion: reduce by cutting durations to near zero rather than to zero, so transitionend listeners still fire and your logic does not stall. For users with vestibular disorders, large parallax and zoom effects are not a preference, they cause nausea.

Keep Going

Free tools, guides, and resources across the SPUNK13 network.

Visit spunk.bet400+ Free Tools
Dev ToolsCasinoMemesAstrologyScam DBBacklinksEbooks