How to make typing animations SSG-safe and hydration-friendly
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:
// 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:
// 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:
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:
// 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.