ehsan.blog
~/blog/how-to-make-typing-animations-ssg-safe — zsh
cat how-to-make-typing-animations-ssg-safe.md

How to make typing animations SSG-safe and hydration-friendly

·5 min read

I wanted a terminal-style typewriter effect on my portfolio — text that appears character by character. The naive way to build it quietly breaks two things on a prerendered site: SEO and hydration. Here’s why, and the small change that makes a typing animation completely SSG-safe.

Why “start from empty” breaks prerendering

A typewriter effect usually works by starting with an empty string and appending one character at a time:

tsx
// The naive approach — DON'T do this on an SSG site
const [displayed, setDisplayed] = useState("")   // starts empty!
useEffect(() => {
  // ...append characters over time
}, [])

Now think about what happens with Static Site Generation, where the build renders your React tree once in Node to produce the static HTML. Effects don’t run during that server render — only the initial state does. So the HTML gets baked with displayed === "". The prerendered page contains empty text where your content should be. A crawler fetching that page sees nothing. All the SEO value of that text is gone.

Why it also causes a hydration mismatch

There’s a second, subtler problem. When React boots on the client it hydrates — it expects the HTML it renders on the first client pass to match the server’s HTML exactly. The trap appears the moment you try to patch SEO by seeding the text differently on the server than on the client: the two HTML strings disagree and React warns about a hydration mismatch. The clean solution sidesteps the whole issue by making the server render and the first client render produce the same, complete text.

The fix: seed state with the full text

Instead of starting empty, start with the entire string. That way the static HTML already contains the real content, and the typing animation becomes a purely visual replay that runs afterward on the client. Here’s my actual hook:

tsx
// src/hooks/use-typing-text.tsx
import { useEffect, useRef, useState } from "react"

export function useTypedText(
  text: string,
  enabled: boolean,
  speed = 45,
  onDone?: () => void,
) {
  // Seed with the full text so the content is present during static
  // generation (SEO + accessibility) and matches the first client render.
  // The typing effect below re-plays from empty when enabled.
  const [displayed, setDisplayed] = useState(text)
  const [done, setDone] = useState(false)
  const onDoneRef = useRef(onDone)
  onDoneRef.current = onDone

  useEffect(() => {
    if (!enabled) {
      setDisplayed(text)
      setDone(false)
      return
    }
    let i = 0
    setDisplayed(text)
    setDone(false)
    const interval = setInterval(() => {
      i++
      setDisplayed(text.slice(0, i))
      if (i >= text.length) {
        clearInterval(interval)
        setDone(true)
        onDoneRef.current?.()
      }
    }, speed)
    return () => clearInterval(interval)
  }, [text, enabled, speed])

  return { displayed, done }
}

The whole trick is line one of state:

tsx
const [displayed, setDisplayed] = useState(text)   // full text, not ""

Because initial state is the complete text:

  • The prerendered HTML contains the real text. Crawlers and screen readers see it. SEO is preserved.
  • Server and first client render agree — both start from the full string — so there’s no hydration mismatch.

Then the effect (which runs only in the browser, never during the build) takes over and replays the typing: it walks i up on an interval and shows text.slice(0, i) until it reaches the full length. The animation is now decoration layered on top of content that already exists, not a mechanism the content depends on.

flowchart LR
  A[useState = full text] --> B[Static HTML has full text: good for SEO]
  B --> C[First client render = full text: no mismatch]
  C --> D[Effect runs in browser]
  D --> E[Replays typing from slice 0..i as decoration]

Only animate when it’s on screen

Notice the enabled flag. My portfolio only triggers the typing when a panel is actually active. Here’s how the experience section uses it:

tsx
// src/components/portfolio/ExperienceSection.tsx
const { displayed: cmdDisplayed, done: cmdDone } = useTypedText(
  "ls ./companies",
  animPhase === "typing",   // enabled only during this phase
  60,
)

When enabled is false, the effect just sets displayed back to the full text and returns early — so off-screen panels still contain their full text, and the animation only plays at the right moment. That keeps the content correct in every state while the flourish stays contextual.

The principle behind it

The general rule for any animation on an SSG site: the final, complete state should be the initial state. Animate toward what’s already there; don’t animate content into existence. That single reframe — decoration on top of real content, rather than content built by the animation — keeps prerendering and hydration happy for typewriters, fade-ins, count-ups, and anything else. It’s the same instinct behind avoiding browser globals during render and mirroring tabbed content into an sr-only block: the real content exists in the static HTML first, and the browser-only behaviour layers on afterward.

What to remember

  • Starting a typing effect from "" bakes empty text into the static HTML — crawlers see nothing.
  • Seed state with the full text so the real content is prerendered and SEO survives.
  • Same initial state on server and client means no hydration mismatch.
  • Run the typing replay in a useEffect — effects are browser-only and never touch the build.
  • General rule: make the finished state the initial state, and treat animation as decoration.
ls ./related
cat ./comments

Comments are not configured yet. Enable GitHub Discussions and paste the giscus repo-id / category-id into src/consts.ts.