How to Animate Only transform and opacity

This guide is part of Animation & Compositing Performance, within Rendering & CSS Performance. The advice "only animate transform and opacity" is easy to repeat and harder to apply: designs ask for panels that slide, cards that grow, shadows that deepen on hover, accordions that expand and progress bars that fill. Each has a natural implementation with a layout or paint property — left, width, box-shadow, height — and a compositor-friendly alternative that needs a little more thought.

Compositor-only animations keep running smoothly even when the main thread is busy with JavaScript, they do not cause layout shifts, and they do not repaint every frame. This guide shows rewrites for the most common animation patterns and the trade-offs each one introduces.

Common animations and their compositor-friendly rewrites Common UI animations implemented with layout or paint properties and their transform or opacity alternatives. Common animations and their compositor-friendly rewrites Effect Expensive version Compositor version Slide in panel left / right translateX Grow on hover width / height scale Deeper shadow on hover box-shadow opacity of shadow layer Progress bar fill width % scaleX from left origin Fade in visibility + color opacity

Rapid Diagnosis

  • Enable Paint flashing and trigger the animation; green flashes on every frame mean paint is involved.
  • Record a trace; Layout events in each animation frame mean layout properties are animated.
  • Open the Animations panel to see each animation's properties and timing.
  • Check CLS attribution for elements that move during animations.

Root Cause Analysis

1. Natural property choices. Designers and developers think in positions and sizes.

2. Library defaults. Some animation libraries animate top/left or dimensions by default.

3. Paint-heavy effects. Shadows, gradients and blurs are expensive to repaint every frame.

4. Content reflow requirements. Some designs want surrounding content to move, which needs layout.

Step-by-Step Resolution

1. Replace position animations with translate

css
.toast { transform: translateY(120%); transition: transform 220ms ease-out; }
.toast.show { transform: translateY(0); }
/* trade-off: the toast's layout box never moves, so it must be positioned
   (fixed or absolute) where it will finally appear. */

2. Replace size animations with scale

css
.card { transition: transform 160ms ease-out; }
.card:hover { transform: scale(1.03); }
/* Progress bar: scale a full-width bar instead of animating width. */
.progress-fill { transform-origin: left; transform: scaleX(var(--p, 0)); transition: transform 300ms; }
/* trade-off: scaling distorts children (text, borders); keep scale small or
   counter-scale children for larger changes. */

3. Replace shadow animations with opacity

css
.tile { position: relative; }
.tile::after {
  content: ''; position: absolute; inset: 0; border-radius: inherit;
  box-shadow: 0 12px 28px rgba(0, 0, 0, .18); opacity: 0; transition: opacity 200ms;
}
.tile:hover::after { opacity: 1; }
/* trade-off: an extra pseudo-element per tile adds a little memory; fine for
   dozens of tiles, worth reconsidering for thousands. */

4. Use FLIP for layout changes that must animate

When an element must actually change layout position (reordering a list, expanding a card into a modal), use the FLIP technique: record the First position, apply the Last layout, Invert with a transform so it appears unchanged, then Play by animating the transform to none.

javascript
function flip(el, change) {
  const first = el.getBoundingClientRect();
  change();                                             // apply the new layout
  const last = el.getBoundingClientRect();
  const dx = first.left - last.left, dy = first.top - last.top;
  const sx = first.width / last.width, sy = first.height / last.height;
  el.animate([{ transform: `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})` }, { transform: 'none' }],
    { duration: 250, easing: 'ease-out' });
}
// trade-off: two layout reads (one forced) per FLIP; for many elements, read all
// "first" rects together, apply the change once, then read all "last" rects.

The FLIP technique The four steps of FLIP for animating layout changes with transforms. The FLIP technique First measure start Last apply new layout, measure Invert transform back to start Play animate transform to none

Verification

With Paint flashing on, the animated element should flash once at most (when its layer is created), not on every frame. A Performance trace of the animation should show frames without Layout or Paint events, and the Frames track should show no dropped frames under 4x CPU throttling. Run a busy-main-thread test: start the animation and simultaneously run a 300ms task in the console; compositor animations continue smoothly.

Worked Example: A Dashboard's Sidebar Collapse

An analytics dashboard collapsed its sidebar by animating width from 260px to 64px; the main content area, with several charts, re-laid out every frame. On laptops with integrated graphics, the 300ms animation dropped half its frames, and charts redrew during the transition. The team changed the sidebar to a fixed 260px element that translates left by 196px, with the content area's offset switching once at the start of the transition (no animation), and charts resizing once at the end. The animation became smooth, and chart redraws dropped from 18 per collapse to one.

When Layout Animation Is Acceptable

Sometimes a layout animation is the right choice: an accordion where content below must move down, or a list item that grows and pushes siblings. Make it as cheap as possible. Keep the animated subtree small and contained (contain: layout on the container), keep durations short (150–250ms), avoid running heavy JavaScript during the animation, and test on mid-range devices. For accordions, the grid-template-rows: 0fr → 1fr technique animates a single grid track rather than many elements' heights, and interpolate-size: allow-keywords allows animating to height: auto where supported. Accept the cost knowingly rather than by default.

Dropped frames in a 300ms sidebar collapse (4x CPU throttle) Bar chart of dropped frames for a sidebar collapse animated with width compared with translateX. Dropped frames in a 300ms sidebar collapse (4x CPU throttle) width 260px to 64px 9frames translateX with one-time offset change 0frames

Common Mistakes

  • Animating top or left on fixed elements. Still layout every frame; use translate.
  • Scaling text-heavy elements a lot. Text looks blurry mid-animation; keep scale changes small.
  • Animating width for progress bars. Use scaleX with a left origin.
  • FLIP for many elements without batching reads. Thrashing on the measurement step.

Edge Cases

Transforms and stacking contexts. A transform creates a stacking context and a containing block for fixed-position descendants, which can change how children render.

Sub-pixel text rendering. Text on composited layers may render with grayscale antialiasing; usually acceptable.

Hit testing. Transformed elements are hit-tested at their visual position, so buttons work as expected during and after animation.

Reduced motion. Wrap animations in prefers-reduced-motion: no-preference or disable them under reduce.

FAQ

Why are transform and opacity cheap to animate?

They do not change layout or the painted content of a layer. The compositor can apply them to an existing layer each frame without involving the main thread.

Does translate3d still matter?

It used to force layer promotion. Modern browsers promote elements with active transform animations automatically; will-change: transform is the explicit, standard way to request it in advance.

Can I animate colours cheaply?

Colour changes require repainting. For large areas, cross-fade two layers with opacity instead; small colour transitions on buttons are usually fine.

Do individual transform properties (translate, scale, rotate) perform the same?

Yes. The individual translate, scale and rotate properties are composited like transform and make it easier to animate one without overwriting others.

Does the Web Animations API run on the compositor?

For transform and opacity, yes, in the same way as CSS animations. It also provides control methods like pause and reverse.

Is filter compositor-friendly?

Some filter animations can be composited in some browsers, but blur in particular can be expensive. Test on target devices.

How do I animate an element appearing in the layout?

Let it take its space immediately (no layout animation), and animate opacity and a small translate for the visual entrance. Surrounding content moves once rather than continuously.

How do I check whether an animation is composited?

Open the Animations panel or record a trace. If animation frames show no Recalculate Style, Layout or Paint events for the element, and Paint flashing stays dark, the animation is running on the compositor.

Is it worth rewriting short hover effects?

For small elements and short durations, the cost of painting is low and rewriting may not matter. Prioritise animations on large areas, animations that run during page load, and anything that moves layout around other content.