Optimizing CSS Animations for 60fps: Budgeting the Frame
At 60 Hz the display asks the browser for a new image every 16.7 milliseconds, and it does not wait. Miss the deadline and the previous frame is shown twice — one visible hitch. Miss it repeatedly during a transition and you get the stuttering that users describe as "cheap". The narrow problem this page solves is arithmetic: knowing how much work fits in a frame, knowing what each animatable property costs against that number, and restructuring an animation so the per-frame cost lands inside the budget instead of just outside it. This page is part of the Performance & GPU Acceleration section of the CSS-Only Micro-Interactions & Animations area.
Key Takeaways:
- The real budget is nearer 10 ms of your own work than 16.7 ms
- Cost is set by which rendering stage a property enters, not by syntax
- Almost every expensive effect has a composited substitute
containbounds the blast radius when you cannot avoid layout
The budget, and who else is spending it
16.7 ms is the interval, not the allowance. Inside it the browser must also process input, run any timers and script that came due, do its own style bookkeeping, rasterise anything newly dirty, and hand the finished layers to the compositor for the display's next scan-out. Treat around 10 ms as the amount of work your animation can cause per frame before you are gambling. On a 120 Hz phone or laptop — now the default on mid-range hardware — the interval halves to 8.3 ms and the realistic allowance is closer to 4 ms.
That number is what makes the property choice decisive rather than stylistic. An animation confined to the composite stage costs a fraction of a millisecond per frame regardless of how large the element is, because nothing is re-rasterised: the same bitmap is drawn at a different position or alpha. An animation that dirties layout costs time proportional to how much of the document has to be re-measured, and that cost is paid again on every one of the sixty frames.
What each property costs
The useful mental model is not "fast properties and slow properties" but "which stage does this property first invalidate". Everything downstream of that stage runs too. A property that dirties layout also forces paint and composite; a property that dirties paint still forces composite; a property that only affects compositing forces nothing else.
| Effect you want | Costly property | Stage it dirties | Composited substitute |
|---|---|---|---|
| Move something | top, left, margin | layout | transform: translate() |
| Resize something | width, height | layout | transform: scale() on a wrapper |
| Fade something | visibility, display | layout | opacity plus pointer-events |
| Raise something | box-shadow | paint | a pre-painted shadow layer faded with opacity |
| Recolour something | background-color | paint | cross-fade two stacked layers with opacity |
| Blur or tint | filter, backdrop-filter | paint | animate opacity on an element that already has the filter |
| Reveal along an edge | clip-path with changing geometry | paint | transform on a child inside an overflow: hidden frame |
Two rows deserve a caveat. filter is composited in current Chromium and WebKit builds for simple cases, but the moment the filtered subtree changes underneath it the whole thing re-rasterises, so treating it as a paint-stage property is the safe default. And clip-path interpolates cheaply when only the shape's coordinates move within an unchanged element — the expensive case is a clip whose bounding box grows, which enlarges the painted region every frame.
The scale-substitute row has a trap worth naming: scaling a box scales its text and its border radius with it, which is rarely what a designer drew. The usual fix is to scale a wrapper that holds only a background, and let the text sit in a sibling that does not transform.
Complete working implementation
A slide-in side panel with a dimming backdrop — a component that is routinely built the expensive way with width or left, and which costs almost nothing when built with transform. This is a complete file; the checkbox is only there so the demo toggles without script.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>60fps slide-in panel</title>
<style>
body { font: 16px/1.5 system-ui, sans-serif; margin: 0; min-height: 100vh; }
.toggle { position: absolute; opacity: 0; }
.toggle-label {
display: inline-block; margin: 2rem;
padding: 0.6rem 1.1rem; border-radius: 8px;
background: #1e293b; color: #fff; cursor: pointer;
}
.toggle:focus-visible + .toggle-label { outline: 3px solid #2d5bff; outline-offset: 3px; }
/* Backdrop: fades with opacity only. pointer-events is toggled so the
invisible backdrop never swallows clicks while the panel is closed. */
.backdrop {
position: fixed; inset: 0;
background: rgb(15 23 42 / 0.5);
opacity: 0;
pointer-events: none;
transition: opacity 240ms ease;
}
.panel {
position: fixed;
inset-block: 0;
inset-inline-end: 0;
width: min(22rem, 85vw); /* a FIXED width — never animated */
padding: 1.5rem;
background: #fff;
box-shadow: -12px 0 32px -12px rgb(15 23 42 / 0.4);
/* The panel is laid out in its open position from the start and simply
parked off-screen. Only the matrix changes when it opens, so no frame
re-measures anything. 101% clears its own shadow. */
transform: translateX(101%);
transition: transform 260ms cubic-bezier(0.32, 0.72, 0, 1);
/* Bound the invalidation: nothing inside this panel can dirty the
layout or paint of anything outside it. */
contain: layout paint;
}
/* Open state. Both properties here are compositor-only. */
.toggle:checked ~ .backdrop { opacity: 1; pointer-events: auto; }
.toggle:checked ~ .panel { transform: translateX(0); }
/* Reduced motion: keep the state change, drop the travel. The panel
cross-fades instead of sliding, which conveys the same thing without
large-area movement across the viewport. */
@media (prefers-reduced-motion: reduce) {
.panel {
transition: opacity 120ms linear;
transform: none;
opacity: 0;
pointer-events: none;
}
.toggle:checked ~ .panel { opacity: 1; pointer-events: auto; }
.backdrop { transition-duration: 80ms; }
}
</style>
</head>
<body>
<input type="checkbox" id="open" class="toggle">
<label for="open" class="toggle-label">Toggle panel</label>
<div class="backdrop"></div>
<aside class="panel">
<h2>Panel</h2>
<p>Slides in on a single composited property.</p>
</aside>
</body>
</html>
Every frame of that animation changes one transform matrix and one alpha value. Neither the panel's contents nor the page behind it is re-measured or re-rasterised, so the per-frame cost is essentially flat no matter how much markup the panel holds.
The technique that makes it work
The declaration doing quiet, disproportionate work is contain: layout paint. Containment is a promise to the engine about isolation: layout says nothing inside this element can affect the geometry of anything outside it, and paint says nothing inside will draw beyond its box. Given that promise, the engine can treat the subtree as an independent unit — when something inside the panel changes, the invalidation stops at the panel's boundary instead of walking up the tree and potentially re-measuring the document.
That matters for animation because layout invalidation is the cost that scales with page size rather than element size. Without containment, a badly behaved descendant — a lazily loaded image resolving its dimensions, a font swapping, a hover rule that touches padding — can force a document-wide re-measure in the middle of your transition, and the animation stutters for reasons that have nothing to do with the animated property. Containment turns "how big is the page" into "how big is this component" for that class of work. It is the right tool precisely when you cannot guarantee that nothing inside the animating subtree will ever dirty layout, which in a real application is most of the time.
Variation: giving low-end devices a cheaper animation
Frame budget is hardware-relative, and CSS can ask about the hardware. The update media feature reports how quickly the output device can repaint, which lets you shorten or drop transitions on slow displays and e-ink without changing the component:
/* Slow-refresh output: a long transition just looks broken here. */
@media (update: slow) {
.panel { transition-duration: 0ms; }
.backdrop { transition-duration: 0ms; }
}
The complementary lever for long lists is not animating what nobody can see. content-visibility: auto lets the browser skip rendering work for off-screen subtrees entirely, which keeps a long page's per-frame cost from growing with its length:
.feed-item {
content-visibility: auto;
/* Required: without a size estimate the scrollbar jumps as items render. */
contain-intrinsic-size: auto 220px;
}
Use it on list items far down a feed, not on anything currently animating — an element that is skipped and then rendered has to paint from scratch, which is the opposite of what you want mid-transition.
Browser support
Everything above is broadly available. contain and contain-intrinsic-size are supported across every current engine; content-visibility is in Chrome 85+, Edge 85+, Firefox 125+ and Safari 18+; the update media feature in Chrome and Edge 113+, Firefox 102+ and Safari 17+. All three degrade to "the browser does its normal work", so no @supports guard is required — though if you rely on content-visibility for a measurable win you can gate enhancements behind @supports (content-visibility: auto). prefers-reduced-motion has been available since Chrome 74, Edge 79, Firefox 63 and Safari 10.1.
Common issues and resolutions
| Symptom | Root cause | Resolution |
|---|---|---|
| Animation is smooth alone, janky during page load | Layout and paint work from images and fonts is competing for the same frames | Reserve space with aspect-ratio and font-display: optional, and delay entrance animations until after load |
| Smooth at 60 Hz, stutters on a 120 Hz screen | The per-frame cost sits between 8.3 ms and 16.7 ms | Reduce the animated area, or move the effect fully onto transform/opacity |
| First frame of every transition hitches | The layer is being promoted at the moment the animation starts | Hint the promotion ahead of time — see the will-change page below |
| Large blurred backdrop tanks the frame rate | backdrop-filter re-rasterises the region beneath it each frame | Keep the blurred region small and static, and animate only its opacity |
Diagnosing which of these you actually have is a measurement task rather than a reading task; the click-by-click method is in profiling animations in DevTools.
FAQ
How much time does one frame at 60fps actually give me? A 60 Hz display refreshes every 16.7 milliseconds, and the browser needs part of that for its own bookkeeping and for handing the frame to the compositor. Budget around 10 milliseconds of your own work per frame, and roughly 4 on a 120 Hz display.
Why is animating box-shadow slow when animating transform is not?box-shadow is resolved during paint, so every intermediate blur radius forces the browser to rasterise a fresh soft gradient. transform is applied when already-painted layers are assembled, so changing it costs one matrix multiplication and no rasterisation at all.
Does a keyframe animation cost more than a transition? No. Transitions and keyframe animations use the same interpolation machinery and the same compositor path. What determines cost is which properties you animate, not which syntax declares the animation.
Can animating a custom property be composited?
Not on its own. A custom property change triggers a style recalculation and then whatever the property feeds into, so it runs on the main thread. Registering it with @property makes it interpolate smoothly but does not move the work to the compositor.
Related
- Performance & GPU Acceleration — the parent section on rendering performance.
- Profiling Animations in DevTools — measure which stage is actually costing you the frame.
will-changeand the Compositor Thread — layer promotion, and the memory price of asking for it.- Animating Custom Properties With @property — what registration does and does not buy you at frame time.
- Preventing Flex and Grid Overflow — layout that stays stable is layout that stays out of your frame budget.
Related articles
More pages in the same section.