treeru.com

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 ScoreRatingPageSpeed Impact
0 – 0.1GoodNo penalty
0.1 – 0.25Needs ImprovementPartial penalty
0.25+PoorSevere 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:

PropertyPreserves SpacePrevents CLSText Selectable
visibility: hiddenYesYesNo
display: noneNoNoNo
opacity: 0YesYesYes

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?

AnimationCLSReason
transform: translateY(60px → 0)Safetransform doesn't affect layout
transform: scale(0.95 → 1)SafeVisual size changes, layout stays fixed
opacity: 0 → 1SafeVisual change only, space unchanged
Framer Motion fadeInUp (y: 60)SafeUses transform internally
fixed/absolute element movementSafeNot in document flow
Dynamic text content additionDangerousHeight/width changes push elements below
Image load (no dimensions set)DangerousSpace expands to image size
Font swap (size difference)DangerousText reflow
Suspense fallback removalDangerousFallback 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.

MetricMobile BeforeMobile AfterDesktop BeforeDesktop After
Performance45708088
CLS0.48100.2520

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.