CLS 0.5 to Zero — The TypeWriter Animation Trap and How to Fix It
We compressed images from 36MB to 0.8MB, but the Mobile PageSpeed score only climbed from 38 to 45 — just 7 points. Images weren't the whole problem. The remaining bottleneck was CLS 0.481. After fixing CLS to zero, Mobile score jumped from 45 to 70 — a 25-point gain with zero design changes. The typing animation responsible for 60–70% of the CLS was invisible to us but screaming at Lighthouse.
What Is CLS and Why It Costs 25% of Your Score
CLS (Cumulative Layout Shift) measures how much page elements unexpectedly move during loading. Reading a news article when an ad suddenly pushes the text down — that's CLS.
| CLS Score | Rating | PageSpeed Impact |
|---|---|---|
| 0 – 0.1 | Good | No penalty |
| 0.1 – 0.25 | Needs Improvement | Partial penalty |
| 0.25+ | Poor | Severe penalty (25% weight) |
Lighthouse allocates 25% of the total Performance score to CLS. With CLS at 0.481, you lose nearly the entire 25% allocation. No amount of image optimization overcomes a CLS this high — fixing layout shift must come first.
Finding the Culprit — TypeWriter Animation
The main page had a TypeWriter component that revealed text one character at a time. It was used in 4 locations: the Hero section title, service section subtitles, process section header, and CTA section header.
Why it causes CLS: The original implementation started with an empty string and added one character at a time.
// Original approach (causes CLS)
const [count, setCount] = useState(0);
// Renders only 'count' characters
<span>{text.slice(0, count)}</span>
// "D" → "Di" → "Dig" → "Digi" → ...
// Width/height changes with each character → CLS!Every character addition changes the text width. When line breaks occur, the height changes too. These shifts push all elements below downward. With 4 sections doing this simultaneously, CLS reached 0.481.
The Fix: Invisible Placeholder Pattern
The idea is simple: render the full text from the start, but make the not-yet-typed portion invisible. The browser sees the full text dimensions from frame one, so layout never shifts.
Choosing the right CSS property matters:
| Property | Preserves Space | Prevents CLS | Text Selectable |
|---|---|---|---|
| visibility: hidden | Yes | Yes | No |
| display: none | No | No | No |
| opacity: 0 | Yes | Yes | Yes |
visibility: hidden is the optimal choice. It preserves space (preventing CLS) while making hidden text non-selectable, so users can't accidentally copy the invisible portion.
// Fixed TypeWriter component core logic
function TypeWriter({ segments, speed = 45 }) {
const [count, setCount] = useState(0);
return segments.map((seg, i) => {
const start = /* segment start index */;
const visibleCount = Math.max(
0, Math.min(seg.text.length, count - start)
);
return (
<span key={i}>
{/* Typed portion — visible */}
{seg.text.slice(0, visibleCount)}
{/* Not yet typed — occupies space invisibly */}
{visibleCount < seg.text.length && (
<span style={{ visibility: "hidden" }}>
{seg.text.slice(visibleCount)}
</span>
)}
</span>
);
});
}The user sees exactly the same animation. Characters appear one by one, the cursor blinks, scroll triggers work identically. But the browser sees the full text dimensions from the first frame — zero layout change.
For accessibility, add aria-hidden="true" to the animation container and provide the full text separately with an sr-only class. Screen reader users get the complete text immediately without the animation.
The Second Trap — Suspense Fallback on Fixed Elements
After fixing the typewriter, CLS dropped from 0.481 to 0.095. Close, but "Good" requires below 0.1. The remaining 0.095 came from an unexpected source.
The layout had this code:
// layout.tsx — the problem
<Suspense fallback={<div className="h-20" />}>
<Header />
</Suspense>The intent was good — reserve 80px for the Header before it loads. But the Header uses position: fixed. Fixed elements don't participate in document flow, so the 80px reservation is unnecessary. What actually happened:
Step 1: Suspense fallback occupies 80px of space. Step 2: Header loads and renders as fixed (exits flow). Step 3: The fallback's 80px disappears — all content shifts 80px upward.
// Fix: fixed elements don't need fallback space
<Suspense fallback={null}>
<Header />
</Suspense>Changing the fallback to null brought CLS from 0.095 to 0.
CLS-Safe vs CLS-Dangerous Animations
Not all animations cause CLS. The key question: does it change layout?
| Animation | CLS | Reason |
|---|---|---|
| transform: translateY(60px → 0) | Safe | transform doesn't affect layout |
| transform: scale(0.95 → 1) | Safe | Visual size changes, layout stays fixed |
| opacity: 0 → 1 | Safe | Visual change only, space unchanged |
| Framer Motion fadeInUp (y: 60) | Safe | Uses transform internally |
| fixed/absolute element movement | Safe | Not in document flow |
| Dynamic text content addition | Dangerous | Height/width changes push elements below |
| Image load (no dimensions set) | Dangerous | Space expands to image size |
| Font swap (size difference) | Dangerous | Text reflow |
| Suspense fallback removal | Dangerous | Fallback space disappears |
Framer Motion's fadeInUp is safe. The site had card animations sliding up from below ({y: 60, opacity: 0} to {y: 0, opacity: 1}). Initially suspected as a CLS source, but the y property internally uses transform: translateY(), which doesn't affect layout. No fix needed.
Results
Only two changes were made: applying the Invisible Placeholder pattern to the TypeWriter component, and changing the Suspense fallback to null.
| Metric | Mobile Before | Mobile After | Desktop Before | Desktop After |
|---|---|---|---|---|
| Performance | 45 | 70 | 80 | 88 |
| CLS | 0.481 | 0 | 0.252 | 0 |
Not a single pixel of design changed. The user-visible experience is identical — same typewriter animation, same layout, same visual hierarchy. But the browser's layout stability measurement went from "Poor" to perfect. Mobile gained 25 points, Desktop gained 8 points, entirely from CLS elimination.
Summary
Typewriter animations are CLS's #1 offender — adding characters changes layout dimensions. The Invisible Placeholder pattern (using visibility: hidden) reserves full text space from the first frame while revealing characters sequentially. Zero visual change, zero layout shift.
Fixed-position Suspense fallbacks cause hidden CLS. If the component uses position: fixed, its fallback shouldn't reserve space. Use fallback={null} instead.
Transform and opacity animations are always CLS-safe. They operate on the compositor layer without triggering layout recalculations. Framer Motion's standard animations (translateY, scale, opacity) need no modification.
CLS carries 25% of the PageSpeed Performance weight. Fixing CLS alone delivered a 25-point Mobile improvement — more than image optimization produced for this site. Always check CLS before optimizing other metrics.