Skip to content
Greeto

June 29, 2026 · 8 min read · Updated June 29, 2026

How to Build Smooth Web Motion Without Jank

Animate transform and opacity only, never layout properties, and respect prefers-reduced-motion to keep web motion smooth on real devices.

By Tal Gerafi, Founder & Website Engineer

Motion & performanceFrontend

To build smooth web motion without jank, animate only transform and opacity. The browser hands these two to the GPU compositor, so they run on a separate thread and never block layout or paint. Animating anything else — width, height, top, left, margin — forces the browser to recalculate the page on every frame, which drops frames on real phones. Then respect prefers-reduced-motion so people who ask for less get it.

That is the whole answer. The rest of this guide explains why those two properties are special, how to catch the layout-thrash mistakes that sneak past you, and how to wire a reduced-motion branch that does not break the page.

Why do transform and opacity animate without jank?

Every frame your browser renders goes through a pipeline: style → layout → paint → composite. Each stage is more expensive than the last, and a 60fps animation has only about 16 milliseconds per frame to finish all of them.

transform and opacity are special because they skip straight to the last stage. The browser can move, scale, rotate, or fade an element by handing it to the GPU compositor as a texture — no layout recalculation, no repaint. The animation runs on the compositor thread, so it stays smooth even when the main thread is busy with JavaScript.

Animate almost anything else and you re-enter the pipeline at an earlier, slower stage:

  • Animating width, height, top, left, or margin triggers layout (also called reflow), then paint, then composite — all three, every frame.
  • Animating background-color, box-shadow, or color skips layout but still triggers paint, which is expensive on large or blurred areas.

So the rule is not a style preference. It is the cheapest path through the browser's own render pipeline. When people say "animate transform and opacity," this is what they mean: stay on the compositor thread and you stay smooth.

How do I move and resize things using only transform?

Most layout-property animations have a transform equivalent that looks identical and costs almost nothing.

What you wantJanky way (triggers layout)Smooth way (compositor only)
Move an elementtop / left / margintransform: translate3d(x, y, 0)
Resize / grow on hoverwidth / heighttransform: scale(n)
Slide a panel inanimate right: 0transform: translateX(0) from offset
Fade content indisplay / visibilityopacity: 0 → 1
Reveal a cardanimate height: autotransform: scaleY() or a measured max-height swap

A few honest caveats so this does not bite you later:

  • transform: scale() scales children too, including text and border-radius, which can look soft. For buttons and cards that is usually fine; for big containers, scale a background layer instead of the whole box.
  • height: auto cannot be animated on the compositor. The common workarounds are animating transform: scaleY() on a wrapper, or measuring the natural height in JavaScript and animating to a fixed pixel value.
  • Add will-change: transform sparingly on elements you are about to animate, then remove it. It promotes the element to its own layer, which helps — but promoting hundreds of layers eats GPU memory and backfires.

If you are building these animations with an AI coding agent, the same constraint belongs in your house rules. We keep "animate transform/opacity only" in our CLAUDE.md so the agent never reaches for layout properties, which means the smooth path is the default path, not a review catch.

What is layout thrash and how do I avoid it?

Layout thrash (also called forced synchronous layout) happens when your JavaScript reads a layout value and then writes one, over and over, in the same frame. Each read forces the browser to recalculate layout immediately so it can give you an up-to-date number — even though it was about to do that work anyway. Do it in a loop and you pay for layout dozens of times per frame.

The classic trigger looks innocent:

// Janky: read, write, read, write... forces layout every iteration
items.forEach((el) => {
  const height = el.offsetHeight;      // READ — forces layout
  el.style.height = height * 2 + "px"; // WRITE — invalidates layout
});

The fix is to batch reads, then batch writes:

// Smooth: all reads first, then all writes
const heights = items.map((el) => el.offsetHeight); // all READs
items.forEach((el, i) => {
  el.style.height = heights[i] * 2 + "px";          // all WRITEs
});

The properties that force a synchronous layout when you read them are the usual suspects: offsetTop, offsetHeight, scrollTop, getBoundingClientRect(), getComputedStyle(), and friends. Read them once, cache the value, and never interleave a read after a write inside the same frame. Scroll handlers are the worst offenders, so move scroll-driven work into a requestAnimationFrame callback and read layout only once per frame. Chrome DevTools flags these in the Performance panel as purple "Layout" bars with a red corner — that red corner is the "forced reflow" warning to hunt down.

How do I respect prefers-reduced-motion correctly?

prefers-reduced-motion is a media query set by the operating system. When someone turns on "reduce motion" in their accessibility settings, the browser reports prefers-reduced-motion: reduce. Honoring it is both an accessibility requirement and a kindness — large transforms and parallax can trigger nausea, dizziness, or migraines for people with vestibular conditions.

The mistake is treating it as an on/off switch that kills every transition. Abrupt jumps with no feedback are confusing. The better approach is to remove movement, keep meaning: drop the big slides, loops, and parallax, but keep fast opacity changes and immediate state cues so the interface still reads clearly.

In CSS, scope your motion inside the "no preference" query so reduced motion is the safe default:

@media (prefers-reduced-motion: no-preference) {
  .card { transition: transform 200ms ease; }
  .reveal { animation: slide-up 500ms ease both; }
}

@media (prefers-reduced-motion: reduce) {
  /* keep a quick fade so state changes are still visible */
  .reveal { animation: fade-in 120ms ease both; }
}

In a JavaScript animation library, read the same signal at runtime — window.matchMedia("(prefers-reduced-motion: reduce)").matches — and branch your variants so reduced users get distance-zero, near-instant transitions. Test it for real: toggle "Reduce Motion" in your OS, or use the emulation toggle in DevTools, and walk the page. If a state change becomes invisible or jarring, your reduced branch is too aggressive.

A 60-second pre-ship motion audit

Before a page goes live, run this quick check. It catches the jank that does not show up on a fast laptop.

  1. Search your styles for animated or transitioned width, height, top, left, margin, box-shadow. Each one is a suspect; move it to transform or opacity.
  2. Open DevTools Performance, throttle CPU to 4x or 6x slowdown, record a scroll and a hover. Look for long frames and red-cornered Layout bars.
  3. Throttle to a real device profile or test on an actual mid-range phone. Desktop GPUs hide a lot of sin.
  4. Toggle Reduce Motion and confirm the page is calm but still legible.
  5. Check the compositor — in DevTools, the "Rendering" tab can highlight paint flashing. If big areas flash green during a "smooth" animation, you are repainting when you should be compositing.

If you want this turned into numbers you track release over release, pair this guide with the motion performance metrics that actually matter — frame consistency and interaction latency give you a before/after you can defend. And for the broader build philosophy behind shipping motion that survives real traffic, see building websites with Claude Code.

FAQ

What CSS properties can I animate without causing jank?

Animate transform (translate, scale, rotate, skew) and opacity. These run on the GPU compositor thread and skip the layout and paint stages of the render pipeline, so they stay smooth even when JavaScript is busy. Avoid animating width, height, top, left, and margin, which force a full layout recalculation every frame.

Why does my animation stutter on mobile but not on my laptop?

Your laptop has a fast CPU and GPU that absorb expensive animations your phone cannot. Layout-triggering properties, heavy blur, and many concurrent animations all cost more on mid-range mobile hardware. Always test with CPU throttling in DevTools or on a real device, because desktop conditions hide most jank.

What is layout thrash?

Layout thrash, or forced synchronous layout, happens when JavaScript reads a layout value like offsetHeight and then writes a style, repeatedly, in the same frame. Each read forces the browser to recompute layout immediately. The fix is to batch all your reads first, then do all your writes, so layout is calculated once instead of many times.

Should I disable all animation for prefers-reduced-motion?

No. Remove large movement, parallax, and loops, but keep fast opacity changes and immediate state cues so the interface stays legible. Scope your motion inside @media (prefers-reduced-motion: no-preference) so reduced motion becomes the safe default, then provide a quick, low-distance alternative for the reduce case.

Does will-change make animations smoother?

It can, by promoting an element to its own compositor layer ahead of time, but it is not free. Each promoted layer consumes GPU memory, so applying will-change to many elements slows the page down instead of speeding it up. Add it only to elements you are about to animate, and remove it when the animation ends.