Text Rendering Costs During Animation

Part of Paint Invalidation & Repaint Boundaries in Performance Budgeting & GPU Architecture.

The problem

An article page animates its headline’s letter-spacing from tight to normal on load, fades body text colour from grey to black as it enters the viewport, and applies a text-shadow that intensifies on hover over pull quotes. Each effect is small in CSS and disproportionately large in a trace: the paint profiler shows text commands dominating every frame, and on mobile the headline animation alone eats most of the frame budget.

Text is the most expensive thing most pages draw.

Root cause analysis: glyphs are rasterised, not copied

Drawing text means turning glyph outlines into pixels: hinting, anti-aliasing, and for subpixel-positioned text, rasterising at a specific fractional offset. Browsers cache rasterised glyphs aggressively, which makes static text cheap to repaint — the cache is reused. Animation defeats the cache in several ways.

Changing glyph geometry invalidates the cache. letter-spacing, word-spacing, font-size, font-weight and font-variation-settings change which glyphs are drawn or where, so cached raster output cannot be reused, and the text is re-shaped and re-rasterised every frame. font-size additionally causes layout.

Changing colour repaints the text. color and text-shadow do not re-shape glyphs but do repaint them, and shadows add a blur pass per frame over the text’s area.

Scaling a promoted layer resamples. A text layer scaled up stretches its texture, which softens glyphs until re-rasterisation, the effect analysed in fixing blurry text after transform animations.

Font loading during an entrance is the worst case. If a webfont finishes loading while an entrance animation runs, the text is re-shaped and re-rasterised mid-animation, and the layout may change — a stutter plus a visible shift.

Transform and opacity on text are cheap. Moving or fading a block of text uses its existing rasterised texture. That is why almost every good text animation is a translate and an opacity change.

Paint time per frame for a 600 px wide headlineMid-range phone, 36 px headline with a webfont.Paint time per frame for a 600 px wide headlinebudget 16.7 mstranslate + opacity0.1 mscolor transition2.8 mstext-shadow transition5.6 msletter-spacing animation9.3 msfont-size animation12.7 ms
Mid-range phone, 36 px headline with a webfont.

Step-by-step resolution

Converting text effectsMove blocks, do not redraw glyphs.Converting text effects1List every animation that touches a text property.The candidates2Replace letter-spacing and font-size effects with translate or scale on a container.No re-shaping3Replace colour fades with an opacity fade of the whole block.No glyph repaint4Pre-paint text-shadow on a duplicate layer and cross-fade it.One blur, not sixty5Await document.fonts.ready before running entrance animations.No mid-animation re-shape6Remove text-specific effects under reduced motion.
Move blocks, do not redraw glyphs.

Production code pattern

// Entrances wait for fonts so text is not re-shaped mid-animation.
await Promise.race([
  document.fonts.ready,
  new Promise((r) => setTimeout(r, 1000)),        // never block the page on a slow font
]);
document.documentElement.dataset.fontsReady = 'true';
/* Headline entrance: move and fade the block, do not animate glyph geometry. */
[data-fonts-ready] .headline {
  animation: headline-in var(--motion-enter-duration) var(--motion-enter-easing) backwards;
}
@keyframes headline-in {
  from { opacity: 0; translate: 0 12px; }         /* never letter-spacing or font-size */
}

/* Body copy: fade the block, not the glyph colour. */
.article__body {
  animation: fade-in var(--duration-300) linear backwards;
  animation-timeline: view();
  animation-range: entry 0% entry 60%;
}
@keyframes fade-in { from { opacity: 0; } }

/* Pull quote emphasis: a pre-painted shadow layer, cross-faded. */
.pull-quote {
  position: relative;
  isolation: isolate;
}
.pull-quote::before {
  content: attr(data-text);                       /* same text, shadowed, painted once */
  position: absolute;
  inset: 0;
  text-shadow: 0 2px 18px rgb(124 58 237 / 0.55);
  color: transparent;
  opacity: 0;
  transition: opacity var(--duration-200) linear;
  pointer-events: none;
}
.pull-quote:hover::before { opacity: 1; }

@media (prefers-reduced-motion: reduce) {
  [data-fonts-ready] .headline, .article__body { animation: none; }
  .pull-quote::before { transition: none; }
}

Rendering Impact: composite for the headline and body; one paint for the pre-rendered shadow layer, then composite. Waiting for fonts trades a slightly later entrance for an entrance that does not stutter and shift.

Duplicating text in a pseudo-element with attr() keeps the shadow layer in sync with the content, but the duplicate is announced by some assistive technology if it is not hidden — color: transparent is not enough. Where the effect matters, mark the visible text and use aria-hidden on a real duplicate element instead.

Text animation choicesAnything that re-shapes or recolours glyphs is paint per frame.Text animation choicesPer-frame costAlternativetranslate / opacityon a blockCompositeAlready optimalcolor transitionRepaints glyphsFade the blocktext-shadowtransitionRepaint plus blurCross-fade a pre-painted layerletter-spacing /word-spacingRe-shape and repaintScale a containerfont-sizeLayout and re-shapeScale a containerfont-variation-settingsRe-shape per frameUse two weights and cross-fade
Anything that re-shapes or recolours glyphs is paint per frame.

Variable fonts and animated weight

Animating a variable font’s weight axis is visually appealing and expensive: every frame produces different glyph outlines, so nothing can be cached. For a single short headline on a fast device it is affordable; for body text, menus or anything on mobile it is not.

The cheaper approximation is to render the text twice at two static weights, stacked, and cross-fade between them with opacity. Both weights are rasterised once, and the transition is composite work. The result is not identical — a true axis animation passes through intermediate weights, while a cross-fade blends two — but at typical durations the difference is hard to see, and the cost difference is large.

Verification checklist

Constraints and trade-offs

  • Waiting for fonts delays entrances slightly; cap the wait.
  • Duplicated text for shadow layers must be hidden from assistive technology.
  • Cross-fading two font weights doubles the text in the DOM.
  • Scaling a container instead of changing font size can blur glyphs while the scale is not 1.
  • Some text effects, such as a typewriter reveal, inherently repaint and should be kept short.

Frequently asked questions

Why is animating text colour expensive?

Every frame repaints the glyphs. Glyph rasterisation is one of the costliest paint operations, and caches only help when the text does not change.

Can I animate letter-spacing?

You can, but it re-shapes and re-rasterises the text every frame. Scale or move a container instead.

Why does my headline animation stutter only sometimes?

A webfont probably finishes loading during the animation, forcing a re-shape and possibly a layout shift. Wait for document.fonts.ready with a timeout before starting entrances.

Is animating variable font weight practical?

For one short headline on capable devices, yes. For body text or mobile, cross-fade two static weights instead.