Fixing Blurry Text After Transform Animations

Part of Layer Promotion & will-change Strategy in Performance Budgeting & GPU Architecture.

The problem

A modal scales in from 0.9 to 1 and its text looks slightly out of focus until the user interacts with it. A card lifts on hover with translateY(-2px) and its label turns fuzzy while hovered. A tooltip centred with translate(-50%, -50%) is sharp on one page and soft on another. A button with will-change: transform looks lighter than identical buttons next to it.

These are four different causes that produce the same complaint. Each comes from moving text onto a compositor layer and then letting the compositor, rather than the text renderer, decide how its pixels land on the screen.

Root cause analysis: text is rasterised once, then treated as an image

When an element is promoted to its own layer, its content — including text — is rasterised into a texture. From then on, compositor animations move, scale and fade that texture without asking the text renderer to redraw. That is the whole point of layer promotion, and it has four side effects on text.

Raster scale. The texture is drawn at a particular scale. If the layer is then scaled up, the GPU stretches existing pixels and glyph edges soften. Browsers normally re-rasterise at the new scale once the animation ends, but an element that keeps will-change: transform is often deliberately left at its old raster scale, because re-rasterising a layer that is expected to keep changing would be wasted work.

Fractional positions. A texture positioned at a half-pixel offset is resampled across two device pixels, which blurs every edge by half a pixel. translate(-50%, -50%) on an element 301px wide produces exactly that offset. Text painted into the page normally has its position snapped; text on a layer moved by a transform may not.

Antialiasing mode. Subpixel (LCD) antialiasing depends on knowing the colour behind each glyph. On a layer with a transparent background the compositor cannot know that, so engines that support subpixel text fall back to greyscale antialiasing on such layers. The text is not blurry, but it looks lighter and thinner than its neighbours.

Mid-animation sampling. During any scale animation, intermediate frames are resampled from the texture. Some softness while moving is unavoidable and mostly invisible at normal durations; the problem is only when it persists.

Four causes of soft text, and how to tell them apartLook at when the softness appears and whether it clears on its own.Four causes of soft text, and how to tell them apartWhen it showsClears by itself?FixRaster scalestretchedAfter scaling upNot with will-changeDrop will-change; scale down to1Half-pixeloffsetAt rest, odd sizesNoRound the translationGreyscaleantialiasingWhile promotedWhen demotedOpaque background or demoteResampling inmotionDuring the animationYesUsually nothing
Look at when the softness appears and whether it clears on its own.

Step-by-step resolution

Diagnosing and fixing soft textWork out which cause you have before changing anything; each fix targets one.Diagnosing and fixing soft text1Screenshot the text before, during and after the animation at DPR 1.Shows whether the blur persists2Remove will-change in the Styles pane and re-trigger the effect.If text sharpens after motion, it was raster scale3Read the element's computed transform matrix for fractional translation values.Non-integer offsets mean resampling blur4Toggle an opaque background-color on the layer.If text darkens, it was antialiasing mode5Apply the matching fix from the matrix and re-check at DPR 1 and 2.Low-density screens show the problem most6Confirm the fix did not reintroduce permanent promotion.
Work out which cause you have before changing anything; each fix targets one.

Production code pattern

/* 1. Modal entrance: rasterise at final size, scale DOWN into place. */
.modal {
  transition: scale 220ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 220ms linear;
}
.modal[data-state="entering"] {
  will-change: scale, opacity;       /* promotion only for the length of the entrance */
}
@starting-style {
  .modal[open] { scale: 1.04; opacity: 0; }   /* start slightly LARGER than final */
}

/* 2. Centred tooltip: pixel-snapped instead of translate(-50%, -50%). */
.tooltip {
  position: absolute;
  left: 50%;
  top: 0;
  /* round() keeps the offset on a whole CSS pixel whatever the tooltip's width. */
  translate: round(-50%, 1px) round(-100% - 8px, 1px);
}

/* 3. Hover lift on a card with text: opaque background keeps antialiasing consistent. */
.card {
  background-color: var(--surface);  /* opaque: the compositor knows what is behind glyphs */
  transition: translate 160ms ease-out;
}
.card:hover {
  translate: 0 -2px;                 /* whole pixels only */
}

@media (prefers-reduced-motion: reduce) {
  .modal, .card { transition: opacity 120ms linear; }
  .card:hover { translate: none; }
  @starting-style { .modal[open] { scale: 1; opacity: 0; } }
}
// Release the promotion once the entrance finishes so the layer can re-rasterise.
modal.addEventListener('transitionend', (e) => {
  if (e.propertyName === 'scale') modal.removeAttribute('data-state');
});

Rendering Impact: composite during motion, one repaint after. Removing will-change at the end lets the browser re-rasterise the modal at scale 1, which is a single paint rather than a permanently soft layer.

Starting slightly larger than the final size is the key detail in the modal pattern. When a layer scales down, the texture is being shrunk, which resamples cleanly; when it scales up from 0.9 it is being stretched. The perceptual difference between 0.96 → 1 and 1.04 → 1 is small, and the second never looks soft.

round(-50%, 1px) uses the CSS round() function to snap a percentage-based offset to whole pixels. Where it is unsupported the whole declaration is dropped, so declare a plain translate: -50% calc(-100% - 8px) first as a fallback.

Measured edge softness on 14px body textWidth of the 10-90% edge transition at DPR 1. Values near 1 px are indistinguishable from unanimated text.Measured edge softness on 14px body textScaled up 0.9 to 1, will-change kept2.1 px blurtranslate(-50%) on a 301px element1.9 px blurScaled up, will-change released1.1 px blurScaled down 1.04 to 11 px blurUnpromoted reference text1 px blur
Width of the 10-90% edge transition at DPR 1. Values near 1 px are indistinguishable from unanimated text.

Verification checklist

Constraints and trade-offs

  • Re-rasterising at the end of an animation costs one paint of the element; for very large layers that paint can itself be noticeable.
  • Scaling down from above 1 means the element briefly occupies more space; allow for it in clipping containers.
  • round() is newer than the other properties here; the fallback declaration keeps layout correct but may keep the half-pixel blur.
  • Opaque backgrounds are not always possible — glass effects and rounded cards on patterned backgrounds are transparent by design; demoting at rest is the alternative.
  • Whether greyscale antialiasing is visible at all depends on the operating system; many high-density displays and platforms use greyscale everywhere.

Frequently asked questions

Why does will-change: transform make text blurry?

It does not by itself. It keeps the element on its own layer at a fixed raster scale, so if the element is then scaled, the browser stretches the existing texture instead of redrawing the text. Remove the hint after the animation, or avoid scaling promoted text upwards.

Should I use translateZ(0) to fix blurry text?

No. Forcing promotion is more likely to cause soft text than fix it, because it introduces the raster-scale and antialiasing issues and consumes GPU memory. Fix the actual cause: fractional offsets, retained promotion or upward scaling.

Why is my text sharp on a phone but blurry on my external monitor?

Half-pixel resampling blurs one device pixel. On a DPR 3 phone that is a third of a CSS pixel and hard to see; on a DPR 1 monitor it is a full CSS pixel. Test at DPR 1.

Does backface-visibility: hidden help?

It is another way of forcing promotion and has the same side effects. It was once a popular workaround for flicker in older engines; it is not a fix for blurry text.