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.
Step-by-step resolution
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-changeat 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.
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.
Related
- Layer Promotion & will-change Strategy — when promotion is worth it and how to release it
- will-change Lifecycle with IntersectionObserver — removing the hint at the right time
- Scaling Small Layers Instead of Animating Large Ones — when stretching a texture is exactly what you want