Why a CSS rotate with will-change: transform makes images and text blurry
If an element is rotated or scaled and has will-change: transform, the browser can rasterise it once and then resample that bitmap every time it moves. Text and thin UI edges come out soft, and no amount of image quality
If an element is rotated or scaled and has will-change: transform, the browser can rasterise it once and then resample that bitmap every time it moves. Text and thin UI edges come out soft, and no amount of image quality fixes it. On my site, app screenshots looked pixelated because every card had a 0.9deg rotation under will-change: transform and a hover scale. Removing the tilt, the scale and the will-change made them sharp.
The report that started this was short: the images on the Masareef, Rafiq and Nafis project pages looked pixelated and bad. The files on disk were fine. Every screenshot was 1200px wide and sharp. The browser was blurring them on the way to the screen.
Why does will-change: transform make things blurry?
will-change: transform tells the browser to promote the element to its own compositor layer. That layer is drawn into a bitmap, and from then on the compositor moves, rotates and scales the bitmap on the GPU without repainting the content.
That is fast, and it is also the problem. A layer that is rasterised once, upright, has to be resampled to be turned. A bitmap that is resampled at an angle cannot keep a one-pixel edge crisp, and small text is made of one-pixel edges. My notes from the fix put it plainly: a rotated raster of small type is never sharp at any angle, and the angle only decides how much.
Scaling has the same cost. In my case the hover state scaled the card by 1.045 on that same layer. The layer was not re-rasterised at the new size, so the card under the cursor was an upscaled copy of its own bitmap. The one card the visitor was looking at was the softest on the page.
How did I confirm the cause?
I read computed style straight from production in the browser the report came from, a 1512px wide window at a device pixel ratio of 2. One evaluation returned three things: the transform matrix matrix(0.999877, -0.0157β¦), will-change: transform, and an image request for w=640&q=75. That was the diagnosis before I changed a line.
The matrix is what a rotation of about 0.9 degrees looks like: the cosine is close to 1 and the sine is about 0.0157. It is easy to dismiss that as "almost not rotated". The raster does not care how small the angle is.
The screenshots were part of a fanned deck. I had added the tilt earlier with a comment that kept it under a degree, because past that the captions baked into the images read as crooked. That earlier work measured the caption tops to within 3px. Nobody zoomed into the pixels on a 2x screen, so the blur was designed in and defended by a comment.
What did the CSS look like before and after?
Before, every card was a fanned, overlapping layer:
.shot {
flex: 0 0 clamp(11rem, 44vw, 15rem);
margin: 0 -1.5rem 0 0; /* overlap */
transition: transform 0.28s cubic-bezier(0.2, 0.7, 0.3, 1);
will-change: transform;
}
.shot:nth-child(odd) { transform: rotate(-0.9deg); }
.shot:nth-child(even) { transform: rotate(0.9deg); }
.shot:hover,
.shot:focus-within {
transform: rotate(0deg) translateY(-0.6rem) scale(1.045);
}
After, the cards are flat. No tilt, no scale, no will-change, and the hover lift is a plain translate that lands on whole pixels:
.shot {
flex: 0 0 clamp(12rem, 58vw, 16.5rem);
margin: 0;
}
.shot a.image-zoom {
transition: translate 0.32s cubic-bezier(0.2, 0.8, 0.2, 1);
}
/* Lift, never scale: a whole-pixel translate is the one transform that does
not resample the screenshot once it lands. */
.shot a.image-zoom:hover,
.shot a.image-zoom:focus-visible {
translate: 0 -0.375rem;
}
The rule I wrote down afterwards: never rotate or scale raster content that carries small type, and never put will-change: transform on anything that scales. If a card needs to lift, translate it by a whole number of pixels.
Was it only the CSS?
No. There were three more causes, and the CSS was the biggest.
The images were AVIF at quality 75, which left ringing around small UI type. Quality 90 was clean for 6 KB more per card, 25 KB to 31 KB at 640px wide. The cards were also 240px wide, a phone screen shown at a fifth of its size, and clicking one opened the raw file in a new tab. I changed the cards to 264px on desktop and 58vw on a phone, cut the srcset to 384, 640 and 828 at quality 90, and opened a full-height viewer on click.
Because Next.js only serves widths and qualities listed in next.config.js, I added q=90 to images.qualities, and a test now reads that config and fails if any rendered image URL asks for a width or quality the optimizer would reject.
Why were the cover banners blurry as well?
A second report came the same day: "I meant this", with a screenshot of the cover banner at the top of the Masareef page, not the gallery. I had fixed the wrong images first. The lesson there was to look at the first viewport before deciding which images a report means.
The banner problem was about device pixels. The source art was a 1024x500 store graphic. The hero rendered 1086 CSS pixels wide, which is 2172 device pixels on a 2x screen, so the browser stretched the file by about 2.1 times. An earlier "hi-res" version at 2560px wide was an upscale of the same 1024 pixels, so it was soft too, only larger. The file size had been the clue: 75 KB for 2560 pixels of supposed detail.
The check I keep now is to compare a raster's intrinsic width with the rendered width multiplied by the device pixel ratio, in device pixels and not CSS pixels. I redrew the covers from SVG at 3x, which gave a 3072x1500 source, and fixed the sizes attribute, which had assumed a 768px column when the header was 1086px wide.
When should text be an image and when should it be live text?
Redrawing the banner at 3x fixed the 2x screen and exposed the next problem a few hours later. A screenshot of the hero at about 1x showed the wordmark and tagline soft next to the real text above them. At 1x the browser was shrinking 3072 pixels into 768, and shrunk type is filtered just like stretched type.
No file size fixes that, because the words were pixels. I had answered "pixelated" with more pixels twice. The question to ask on the first report is whether the thing is text.
The hero of the five mobile app pages is now live HTML in components/projects/app-banner.tsx: the name, the Arabic name, an accent rule and the tagline are real text over a CSS background of two radial glows and a light SVG grain to avoid banding. The app icon is the only image. The frame is a query container, so the type scales with its own width and one rule covers both the 768px column and a 310px phone column.
The rule that came out of it: text a reader has to read is live text. A raster of type belongs only where the page cannot draw text, such as link-preview cards and store listings. The same lesson says to check sharpness at the pixel density of the screenshot you were sent. My test rig had emulated a 2x laptop, where the 3x cover looked fine, and the report came from a screen at about 1x.
A checklist for blurry images and text in CSS
- Search the stylesheet for
will-change: transformand for anyrotate()orscale()on elements with images or text. Remove them from anything that carries small type. - Read the computed
transformin devtools. A matrix that is not the identity on a card you thought was flat is a lead. - Compare the image's intrinsic width with rendered width times device pixel ratio.
- Test at 1x and at 2x. A fix that is sharp at one density can be soft at the other.
- If the blurry thing is text, make it text.
You can see the finished pages on the Masareef, Rafiq and Nafis case studies. The site they live on is described in the portfolio write-up.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.