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.
Step-by-step resolution
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.
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.
Related
- Paint Invalidation & Repaint Boundaries — the parent topic
- Reading the Paint Profiler in DevTools — seeing text commands in a trace
- Scroll-Driven Text Highlight Effects — text effects that stay affordable