Fixing a 0.26 CLS Score From a Typewriter Headline
TL;DR
A typewriter headline changes how many lines the heading needs while it types, which moves everything below it. Reserving the height of the longest phrase with an invisible copy in the same CSS grid cell removed the shift completely: CLS went from 0.263 to 0.001.
Key takeaways
- Any text that changes length after first paint can shift the layout, even if it never leaves its container.
- PageSpeed Insights points at the element that moved, not the element that caused the move.
- An invisible copy of the longest variant, stacked in the same grid cell, reserves the exact height for every screen width.
- A fixed min-height only works for the screen widths you tested.
My portfolio homepage opens with a headline that types itself out: "Hi, I'm Sandeep", then "Nepal's Top Software Developer", then a few more phrases, deleting and retyping in a loop. It looks lively. It also gave the page a Cumulative Layout Shift (CLS) of 0.263 on desktop and 0.221 on mobile in PageSpeed Insights, which Google classes as poor.
This post walks through how I found the cause, why the obvious fix did not work, and the small CSS change that brought CLS down to 0.001.
What CLS actually measures
Cumulative Layout Shift adds up every unexpected movement of visible content while the page is open. Each shift is scored by how much of the screen moved and how far it moved. A score of 0.1 or less is good. Above 0.25 is poor.
The important word is unexpected. Movement right after a click or a key press does not count. A headline that types itself with no user input is, as far as the browser is concerned, unexpected.
Following the evidence
PageSpeed Insights lists "layout shift culprits". Mine said the shifted element was the Featured Projects section, scoring 0.261 on its own. Several tiny entries pointed at the blinking cursor inside the headline.
That is a common trap. The report names the element that moved, not the element that caused the move. The project section did nothing wrong. It was simply sitting below a heading that kept changing height.
The phrases are different lengths. On a narrow phone, "Hi, I'm Sandeep" fits on one line while "Hi, I'm Nepal's Best Technical Analyst" needs two or three. Every time the animation moved to a phrase with a different line count, the heading grew or shrank, and the whole page below it jumped.
I confirmed it with a few lines of JavaScript in the browser console, sampling the heading's height every half second:
const h1 = document.querySelector("h1");
setInterval(() => console.log(h1.getBoundingClientRect().height), 500);
On a 375-pixel-wide screen the height alternated between 135 and 122 pixels. That 13-pixel jump, repeated every few seconds, is the shift.
Why min-height was not enough
The component already had a fix in place: a min-height of 3.4em on mobile and 2.4em on wider screens, sized for the tallest phrase. It worked on the screens it was tested on.
The trouble is that a min-height is a guess. How many lines a phrase needs depends on the viewport width, the font, the font size and even whether the web font has loaded yet. A value that is right at 375 pixels can be wrong at 390. Lighthouse's mobile test happened to land on a width where the guess was too small.
The fix: reserve the space with the real text
Instead of guessing the height, let the browser measure it. The heading becomes a one-cell CSS grid holding two layers in the same cell:
- An invisible copy of the longest phrase. It is laid out like normal text, so it takes exactly the height that phrase needs at the current width.
- The real, animated text, on top of it in the same cell.
A grid cell is as tall as its tallest child, and the invisible copy is always the tallest. The heading can never be shorter than the longest phrase, so nothing below it moves.
const longestHeroPhrase = profile.heroPhrases.reduce(
(best, p) => (p.length > best.length ? p : best),
"",
);
<h1 className="grid text-4xl font-bold leading-tight sm:text-5xl">
<span aria-hidden="true" className="invisible col-start-1 row-start-1">
Hi, I'm {longestHeroPhrase}
<span className="ml-1 inline-block font-normal">|</span>
</span>
<span className="col-start-1 row-start-1">
Hi, I'm <Typewriter phrases={profile.heroPhrases} />
</span>
</h1>
Three details matter:
visibility: hidden, notdisplay: none. A hidden element still takes up space, which is the whole point.display: nonewould remove it from layout.aria-hidden="true"keeps screen readers from announcing the phrase twice.- The cursor character is included in the invisible copy, because the blinking
|adds width and can push the last word onto a new line.
"Longest" here means most characters, which is a good stand-in for the most lines. If your phrases use very different word lengths, pick the variant that wraps the most and pass it in directly.
The results
I ran PageSpeed Insights again after deploying this fix together with a few smaller speed changes, such as inlining the stylesheet. The CLS numbers come from this fix alone:
| Metric | Before | After |
|---|---|---|
| CLS, desktop | 0.263 | 0.001 |
| CLS, mobile | 0.221 | 0.001 |
| Desktop performance score | 85 | 100 |
| Mobile performance score | 87 | 94 |
The heading also stopped being a problem for Largest Contentful Paint. Because the server renders the longest phrase first, the largest text block paints at first paint instead of appearing gradually as the animation types it.
The general lesson
Any content that changes size after the page loads can cause layout shift: rotating testimonials, live counters, "updated 3 minutes ago" labels, and animated headlines. The fix is the same each time. Work out the largest version the content will ever be, and reserve that space before the first paint.
The invisible-copy grid is the most reliable way I know to do that for text. It adapts to every screen width and font automatically, because the browser lays out the real words rather than following a number someone typed into a stylesheet.
Frequently Asked Questions
What is a good Cumulative Layout Shift score?
Google treats 0.1 or less as good and anything above 0.25 as poor. CLS adds up unexpected layout movements during the page's life, weighted by how much of the screen moved and how far.
Why didn't min-height fix the typewriter shift?
A min-height is a guess in ems or pixels. The number of lines a phrase needs depends on the screen width and font, so a value that works on one phone can be too short on another.
Does the invisible copy hurt SEO or accessibility?
The copy is marked aria-hidden, so screen readers skip it, and it uses visibility hidden rather than display none so it still takes up space. Search engines read the real heading text.
Sandeep Kumar Chaudhary
Full Stack Software Developer· Nepal's SEO, AEO, GEO & AIO expert and share-market educator. More about me
