← back

The blur that switched sides

You have a panel with labels on it, a diagram say, and two buttons: plus and minus. Plus sets transform: scale(1.5) on the panel, minus sets it back. You can also drag the panel around. It works, except that after you press plus the labels are soft, and you can’t unsee it.

So you do what the articles say. The panel moves a lot, so you add will-change: transform, because that’s the modern, responsible way to tell the browser so. And the labels get worse. Blurry at every zoom level above the first one, and they stay that way for as long as the panel is zoomed in.

You take will-change off and try the old thing instead, transform: translateZ(0), the hack performance articles have spent a decade telling you to stop using. The labels are sharp at every zoom level.

That’s backwards, or it should be. The recommended property blurs the text; the discouraged hack doesn’t. Neither of them mentions text at all. To see why, you have to look at what a browser has in its hands when it has a piece of text on a layer, and what it decides to do with it when you ask for a different size.

What a layer is holding

If you’ve read the layers post, you have most of this already. The short version: when an element gets its own compositor layer, the browser draws it once into a bitmap, hands the bitmap to the GPU, and from then on moving the element means moving the bitmap. That’s why moving something with a transform is cheap. Nothing gets redrawn; a rectangle of pixels gets placed somewhere else.

The word for that drawing step is rasterising, and it’s the expensive part. For text it means shaping the string into glyphs, working out where each glyph’s outline crosses each pixel, and computing a grey level for every pixel the outline only partly covers. On a Retina screen a 15 pixel font is drawn at 30 device pixels, so every letter is a small, carefully computed grid of greys, and the edges are exact at that size and only that size.

Now scale the layer up by half. The bitmap is the wrong size. The browser has two options.

It can draw the text again at the new size, which costs another raster: shaping, outlines, coverage, all of it, for every glyph on the layer. Or it can keep the bitmap it has and let the GPU stretch it, which costs almost nothing, because stretching a texture is what GPUs do all day. Stretching a 30 pixel letter to 45 pixels means inventing 15 rows of pixels that were never computed, and the GPU invents them by averaging their neighbours. Every edge that was one pixel wide becomes a smear one and a half pixels wide.

The two choices at the pixel level, for the same word at the same size on screen:

drawn again at 1.5×
Zoomed pixels of the word Sphinx with crisp edges
drawn at 1×, stretched to 1.5×
Zoomed pixels of the word Sphinx with soft grey edges
Both are 1.5 times the text's normal size. On the left the browser drew the text again at that size. On the right it kept the drawing it already had and stretched it. Every pixel is shown six times larger, with no smoothing, so what you see is what was on the screen.

The left one was drawn again at 1.5×. The right one was drawn at 1× and stretched. Both are 1.5× on the screen. That’s the whole difference between crisp and soft, and it’s a decision the browser makes per layer, every time the scale changes.

So the question is which one it picks, and when.

The rule, and where it’s written

Chrome’s answer is in a post on the Chrome developers blog from September 2016, by Chris Harrelson, that I’d guess very few people have read, because it has the least exciting title imaginable: “Re-rastering composited layers on scale change”. Its first sentence:

Starting in Chrome 53, all content is re-rastered when its transform scale changes, if it does not have the will-change: transform CSS property.

Everything gets redrawn at the new scale, except things with will-change: transform. Those, the post goes on, are “rastered into a fixed bitmap, which subsequently never changes under transform updates.”

That’s the panel. When you added will-change, you told Chrome this element was going to transform a lot, and Chrome took you at your word: it drew the text once, at the size it had at that moment, and kept using that drawing whatever the transform did next. Then you scaled it up, and the GPU stretched the one drawing it had.

The hack doesn’t say that. translateZ(0) creates a layer too, but it makes no promise about the future, so when the scale changes Chrome falls into the first half of the sentence and redraws. Sharp.

I wanted to see this rather than take it from a blog post, so I put the same line of text on a page seven times: one copy scaled to 1.5× in the stylesheet as the reference, the other six each promoted a different way, and after the page had painted, a script set scale(1.5) on those six. Then I screenshotted at 2× and counted, for each copy, what share of its ink was a middling grey, an edge, rather than a solid pixel. Text drawn fresh at 1.5× in that font comes out at 23%. What each way of making a layer did:

promoted bysoft edge share after the scale change
nothing (scale in the stylesheet from the start)23%
will-change: transform45%
transform: translateZ(0)23%
transform: translate3d(0, 0, 0)23%
backface-visibility: hidden23%
will-change: opacity23%
will-change: top23%

One row is different. The 3D hack, the backface trick and will-change for other properties all redrew at the new size. Only will-change: transform kept the old drawing. Its edges went from 23% soft to 45%, which is the pixel figure above expressed as a number. At scale(2.5) the same layer read 47%, still stretched from the 1× drawing. Ten seconds later, still stretched. After the text colour was changed, which forces a repaint of the layer, still stretched: a repaint redraws the pixels, but it redraws them at the raster scale the layer already has.

You can see this in your own Chrome. Two cards, same text, one promoted each way. The slider scales both with a plain style change, no animation:

a plain style change, no animationscale 1.00
will-change: transform
Card one

Sphinx of black quartz, judge my vow. The quick brown fox jumps over the lazy dog.

transform: translateZ(0)
Card two

Sphinx of black quartz, judge my vow. The quick brown fox jumps over the lazy dog.

left card, in Chrome
drawn once at this size
right card, in Chrome
drawn again at each size
will-change on
Drag the slider. In Chrome the left card goes soft as it grows, the right card stays sharp. The button takes will-change off the left card, and it sharpens on the spot.

In Chrome the left card goes soft as the slider moves and the right card doesn’t, and pressing the button to take will-change off the left card redraws it on the spot, because Chrome is back in the first half of Harrelson’s sentence. If you’re reading this in Firefox, both cards are sharp at every size. Firefox redraws glyphs at the new transform when a script changes it, and will-change pins nothing there. I ran the will-change rows through it and each came back as sharp as the reference.

Chrome also writes down what it decided, per layer, if you know where to look. A tracing category called disabled-by-default-cc.debug records every compositor layer with two numbers: the scale the layer ideally wants, and the scale it was actually rastered at. For the left card at the slider’s 2× position, the trace on my machine says ideal 2, raster 1. For the right card, ideal 2, raster 2. The source that produces those numbers has the reason in a comment, in the file that decides raster scales:

We want to use the same raster scale as much as possible during the lifetime of a will-change:transform layer to avoid rerasterization.

Why it’s still the right trade

Before you go and delete will-change from everything, it helps to know what Chrome is protecting.

Imagine the scale isn’t a jump but an animation: sixty frames a second, the panel growing smoothly from 1× to 1.5× over a third of a second. If Chrome redrew the text at every frame’s scale, that’s twenty rasters of every glyph on the layer in three hundred milliseconds, competing with everything else the page needs drawn. That is exactly the jank the compositor exists to avoid. So for a running animation Chrome does something else: it looks at the animation’s largest scale, rasters the layer once at that scale, and lets the GPU shrink it for every frame before the end. Shrinking a bitmap barely shows. I measured a card mid-animation at 1.41× and its edges read 27%, next to the 23% of a fresh draw, and the trace showed it rastered at 2, the animation’s end point.

Chrome does that for any animation, will-change or not. What will-change: transform adds is a promise that this element is about to do that kind of thing, a lot, so Chrome keeps the peak raster after the animation ends instead of throwing it away, and the next animation starts without a redraw. The pinned raster is the cost of taking the promise seriously. The mistake in the panel was pairing a property that means “this will animate constantly” with something that jumps once and sits there.

So the rule you want is smaller than either camp’s advice. Text that animates its scale: Chrome rasters at the peak, and will-change: transform keeps that raster warm between animations. Text that jumps to a scale and stays: no will-change, and Chrome redraws at the new size. Text that does both, which is the zoomable panel: make the jump a transition. Chrome treats a transition like an animation and rasters at its end scale. I measured a card mid-transition at 1.39× and it read 28%, rastered at 2, the transition’s end point. A will-change layer taken to 1.5× by a transition, then to 2× by another, then back to 1× and up again, was pixel-identical to the stylesheet reference at every stop. The panel’s labels go soft only because plus sets the scale in one step, with no transition to give Chrome a peak to draw at.

The other blur, and why everyone confused the two

There’s an older reason text changed when it landed on a layer, and it’s the one the folklore is about. It has nothing to do with scale.

A pixel on an LCD is three vertical stripes, red, green and blue, side by side. If you know that, you can cheat: instead of computing one grey coverage value per pixel, compute three, one per stripe, and get three times the horizontal resolution for the edges of your letters. That’s subpixel antialiasing, ClearType on Windows, “font smoothing” on the Mac that had it. The catch is that the three values only work if you know what colour is behind the text, because you’re not drawing grey, you’re drawing tinted fringes that have to add up to the right colour with the background. On a transparent bitmap that gets composited later, there is no background yet. Microsoft’s own DirectWrite documentation says it in one sentence: because there is no single alpha value per pixel, ClearType “is not suitable for rendering text onto a transparent intermediate bitmap”.

So when text moved onto a layer that wasn’t opaque (in WebKit, any transformed text), browsers dropped the three-stripe method and drew plain grey. The text looked lighter and slightly different, and it popped from one look to the other the instant the layer was created. The will-change spec later wrote the pop into the property itself: if a property would change how an element renders, “the user agent should use that alternate rendering when the property is specified in will-change”, so the change happens before the animation rather than during it. WebKit knew before the hack existed. Simon Fraser filed the bug in January 2009, and in February explained that it was a choice: turning smoothing off for transformed text meant “the appearance of the text will not change when the transformed element pops into a layer”, and, he added, “it gives web authors a back-door way to control font smoothing”.

The back door opened nineteen months later. In August 2010 Dean Jackson at Apple was quoted on Thomas Fuchs’s blog saying that “any transform that has a 3D operation as one of its functions will trigger hardware compositing, even when the actual transform is 2D, or not doing anything at all (such as translate3d(0,0,0))”, followed by a line that travelled much less far than the first one: “this is just current behaviour, and could change in the future”, which was why, he said, Apple didn’t document or encourage it. Fuchs’s post is gone; the quote survives in David DeSandro’s 24 ways article from that December. Within a year Stack Overflow had a question titled “Webkit-based blurry/distorted text post-animation via translate3d”, whose accepted answer was an opaque background plus a font-smoothing line, and the decade of threads after it kept to the same three moves: an opaque background, -webkit-font-smoothing: antialiased so the text is grey everywhere and can’t pop, or another layer trigger so the pop happens before anyone’s looking.

Chrome ended up writing the whole decision down as a list. In 2020 the compositor gained an enum, LCDTextDisallowedReason, and every layer carries one value from it: none, setting, background-color-not-opaque, contents-not-opaque, non-integral-translation, non-integral-x-offset, non-integral-y-offset, will-change-transform, pixel-or-color-effect, transform-animation, no-text. It’s the same trace as before. And it’s how you find out that on a Mac none of this applies any more: every layer on my machine, including the page’s own background, reads setting, because Chrome asks macOS whether subpixel text is available and macOS says no. Apple turned it off by default in Mojave, in 2018, and the closest thing to an official statement is one line in a WWDC session: “In macOS 10.14, we no longer use that effect.” Chrome started asking the OS in 2020, and WebKit deleted its own layer-text machinery in 2022. On Windows, going by the source, the list is live, and text that isn’t on an opaque background, or is under will-change: transform, or is mid-animation, is still drawn grey.

Which means the folklore describes two different blurs. One is grey-versus-fringed, popped when a layer appeared, and is now a story for Windows and whatever else still has three-stripe text. The other is drawn-versus-stretched, appears when a scale changes, and is what your panel is doing in Chrome on any OS. They got one name and one set of fixes because both showed up right after someone typed translate3d.

The line in every reset

The last piece of the folklore is the one you’ve most likely shipped.

Plenty of CSS resets have a line for the Mac: -webkit-font-smoothing: antialiased. The reason given is that macOS renders web text too heavy, and this switches subpixel rendering off so it matches the rest of the OS. A widely copied reset and a well-read 2024 article both say a version of that today.

There has been no subpixel text in Chrome on a Mac since 2020, and none in the OS since 2018. So what does the line do? I measured that too: the same text with and without it, on the same Mac, in Chrome. The edges were identical. The ink was 9% less. The letters got thinner, not sharper, and they got thinner because of a separate thing Apple kept when it dropped the colour fringes: a slight thickening of every stroke, which a Firefox engineer, having probed it pixel by pixel, described as “macOS 10.14 decided to remove the subpixel anti-aliasing itself, but keep the dilation.” The reset line turns “font smoothing” off, by whichever path the browser takes to the OS, and on a modern Mac “smooth” means dilate. It still does something. It just isn’t the thing its name says, and it hasn’t been for years.

Two blurs, one folklore

The hack you were told to stop using is innocent of this one, in any Chrome since 53. It makes a layer and promises nothing, so the layer gets redrawn. It was guilty of both blurs before that: of the grey pop, on Windows and on Macs that still had three-stripe text, and of the stretch too, because until 2016 Chrome didn’t redraw any layer on a scale change, which is what a 2011 Stack Overflow answer meant by “Webkit treats 3d transformed elements as textures instead of vectors”. The guilt outlived the crime because the two blurs looked alike from the outside.

The line you paste to fix the Mac is doing a third thing, to a feature that isn’t the one it’s named for.

None of this is hidden. The browsers wrote down every one of these decisions, in a bug, a blog post, an enum and a trace, and they wrote them down where nobody looks, which is inside the browser. The folklore grew where everybody looks, which is the first answer on the page.

So, the panel. Its labels went soft because you changed their scale in one step and Chrome, honouring the promise you made with will-change, stretched the drawing it had instead of making a new one. That’s a rule from 2016, in a blog post with a boring title and a comment in the compositor’s source, and it’s a good rule for the thing the property is for. The hack stayed sharp because it never made the promise. Take will-change off the panel, or make plus a transition so Chrome has a peak to draw at, and the labels are sharp at every zoom level, for a reason you can now read off a trace.