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
  • contain bounds 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.

One frame measured against the 60 Hz budget A frame that animates width runs style, layout, paint and composite and overruns the 16.7 millisecond deadline, while a frame that animates transform only composites and finishes far inside it. What fits in one frame 16.7 ms deadline animating width style layout paint composite overruns: the previous frame is shown twice animating transform composite one pass; the rest of the budget stays free

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 wantCostly propertyStage it dirtiesComposited substitute
Move somethingtop, left, marginlayouttransform: translate()
Resize somethingwidth, heightlayouttransform: scale() on a wrapper
Fade somethingvisibility, displaylayoutopacity plus pointer-events
Raise somethingbox-shadowpainta pre-painted shadow layer faded with opacity
Recolour somethingbackground-colorpaintcross-fade two stacked layers with opacity
Blur or tintfilter, backdrop-filterpaintanimate opacity on an element that already has the filter
Reveal along an edgeclip-path with changing geometrypainttransform 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

SymptomRoot causeResolution
Animation is smooth alone, janky during page loadLayout and paint work from images and fonts is competing for the same framesReserve space with aspect-ratio and font-display: optional, and delay entrance animations until after load
Smooth at 60 Hz, stutters on a 120 Hz screenThe per-frame cost sits between 8.3 ms and 16.7 msReduce the animated area, or move the effect fully onto transform/opacity
First frame of every transition hitchesThe layer is being promoted at the moment the animation startsHint the promotion ahead of time — see the will-change page below
Large blurred backdrop tanks the frame ratebackdrop-filter re-rasterises the region beneath it each frameKeep 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 articles

More pages in the same section.