Open the Astro Rocket About page and the headline does not sit still. It types one word, pauses, deletes it, and types the next — looping forever. This post explains exactly how that effect is built, what each value controls, and how to make it faster, slower, or gone entirely.
Where the component lives
The entire effect is self-contained in one file:
src/components/ui/TypingEffect.astro
It is a standard Astro component with a scoped <style> block for the cursor blink and a <script> block for the typing logic. No third-party library, no external dependency.
Where it is used
The component currently lives in the About page hero, inside a brand-coloured <span>:
<h1 slot="title">
<span class="text-foreground [-webkit-text-fill-color:currentColor]">Astro Rocket —</span><br />
<span class="text-brand-500 [-webkit-text-fill-color:var(--color-brand-500)]">
<TypingEffect words={["Web Designer", "Web Developer", "Astro Developer", "Blogger", "Coffee lover"]} />
</span>
</h1>
The homepage hero uses static text. The typing effect was kept on the About page where it acts as a personal “who am I” cycling statement rather than a product tagline.
You can place <TypingEffect> inside any heading or inline context. The words prop is the only required value.
How the animation works
The component uses a single recursive setTimeout loop — no setInterval, no requestAnimationFrame. Each call to tick() decides whether to add or remove one character, then schedules the next call after the appropriate delay.
Start
└─ wait 600 ms (initial settle delay)
└─ tick()
├─ typing: add one character, wait typeSpeed ms
│ └─ when word is complete: wait pauseAfterType ms, then switch to deleting
└─ deleting: remove one character, wait deleteSpeed ms
└─ when empty: wait pauseAfterDelete ms, advance to next word, switch to typing
The 600 ms initial delay exists so the animation does not start mid-paint on a slow connection.
The full script, exactly as it runs today:
function startTyping() {
const root = document.getElementById(id);
if (!root) return;
const textEl = root.querySelector('.typing-text');
// Lock the element width to the widest word so the layout never shifts
const measurer = document.createElement('span');
measurer.setAttribute('aria-hidden', 'true');
measurer.style.cssText = 'visibility:hidden;position:absolute;white-space:nowrap;pointer-events:none;';
const cs = getComputedStyle(root);
measurer.style.font = cs.font;
measurer.style.letterSpacing = cs.letterSpacing;
document.body.appendChild(measurer);
let maxWidth = 0;
for (const word of words) {
measurer.textContent = word + '|'; // include cursor character in measurement
maxWidth = Math.max(maxWidth, measurer.offsetWidth);
}
document.body.removeChild(measurer);
root.style.minWidth = maxWidth + 'px';
let wordIndex = 0;
let charIndex = 0;
let isDeleting = false;
let timer;
function tick() {
const current = words[wordIndex];
if (isDeleting) {
charIndex--;
textEl.textContent = current.slice(0, charIndex);
if (charIndex === 0) {
isDeleting = false;
wordIndex = (wordIndex + 1) % words.length;
timer = setTimeout(tick, pauseAfterDelete);
return;
}
timer = setTimeout(tick, deleteSpeed);
} else {
charIndex++;
textEl.textContent = current.slice(0, charIndex);
if (charIndex === current.length) {
isDeleting = true;
timer = setTimeout(tick, pauseAfterType);
return;
}
timer = setTimeout(tick, typeSpeed);
}
}
// Start after a short initial delay so the page paint settles
timer = setTimeout(tick, 600);
// Clean up pending timer when navigating away
document.addEventListener('astro:before-swap', () => clearTimeout(timer), { once: true });
}
// Run on initial load and on every client-side navigation back to this page
document.addEventListener('astro:page-load', startTyping);
Why this component forks from the obvious implementation
A naive typing effect takes about twenty lines: set an interval, increment a character index, write to a DOM node. That works fine in isolation. Astro Rocket runs Astro’s ClientRouter for client-side navigation, lives in a heading (where descenders are visible), and cycles through words of different lengths — each of those facts breaks the naive version in a different way. Here is what was added and why.
Fix 1 — Client-side navigation (astro:page-load)
Astro’s ClientRouter swaps pages by replacing DOM nodes without a full browser reload. A plain top-level script runs once when the browser first parses the page. When the user clicks a link and then hits back, the DOM is swapped back in but the script does not re-run — the animation stays frozen.
The fix wraps the entire animation in a startTyping() function and registers it on astro:page-load, which Astro fires on both the initial load and every subsequent client-side navigation:
document.addEventListener('astro:page-load', startTyping);
The companion cleanup is equally important. If a pending setTimeout from a previous visit is still in flight when the user navigates away, it can fire against a DOM element that no longer exists. astro:before-swap fires just before Astro tears down the current page, so clearing the timer there prevents stale callbacks:
document.addEventListener('astro:before-swap', () => clearTimeout(timer), { once: true });
{ once: true } ensures the listener removes itself after the first navigation so it does not accumulate across repeated visits.
Fix 2 — Layout shift (width locking)
When the animation cycles through words of different lengths — “Web Designer” is longer than “Blogger” — the element changes width on every word transition. Everything to the right of it (or below it on a wrapped line) shifts. This is a jarring visual jump and a real Core Web Vitals hit.
The fix measures every word before the animation starts, using a hidden off-screen <span> that inherits the same font and letter-spacing as the real element. The widest measurement (including the cursor character |) is applied as minWidth:
const measurer = document.createElement('span');
measurer.style.cssText = 'visibility:hidden;position:absolute;white-space:nowrap;pointer-events:none;';
measurer.style.font = getComputedStyle(root).font;
measurer.style.letterSpacing = getComputedStyle(root).letterSpacing;
document.body.appendChild(measurer);
let maxWidth = 0;
for (const word of words) {
measurer.textContent = word + '|';
maxWidth = Math.max(maxWidth, measurer.offsetWidth);
}
document.body.removeChild(measurer);
root.style.minWidth = maxWidth + 'px';
The measurer is appended to <body> (not inserted inline) so it does not inherit any overflow clipping from ancestor elements. It is removed immediately after measurement.
Fix 3 — Descender clipping (overflow hidden removed)
The .typing-effect span originally had overflow: hidden — a common guard when animating text to prevent runaway characters from bleeding outside the box. The problem is that overflow: hidden clips the descenders of letters like g, j, p, q, and y. In a heading at large font sizes this is very visible: the bottom of those letters looks cut off.
The fix is simply to remove overflow: hidden. Width is already controlled by minWidth from Fix 2, so there is nothing to clip. The remaining styles are:
.typing-effect {
display: inline-block;
white-space: nowrap;
vertical-align: bottom;
}
vertical-align: bottom aligns the inline-block to the text baseline of the surrounding line, which keeps the heading vertically stable as words change length.
The props and their defaults
| Prop | Default | What it controls |
|---|---|---|
words | (required) | Array of strings to cycle through |
typeSpeed | 120 | Milliseconds between each character typed |
deleteSpeed | 70 | Milliseconds between each character deleted |
pauseAfterType | 1800 | Pause in ms after the word is fully typed |
pauseAfterDelete | 400 | Pause in ms after the word is fully deleted |
How to adjust the speed
Pass any combination of props directly on the component. You only need to set the values you want to override:
<TypingEffect
words={["Web Designer", "Web Developer", "Astro Developer"]}
typeSpeed={80}
deleteSpeed={40}
pauseAfterType={2500}
pauseAfterDelete={200}
/>
Faster, snappier feel — lower typeSpeed and deleteSpeed, shorten both pauses:
<TypingEffect
words={["Designer", "Developer", "Builder"]}
typeSpeed={60}
deleteSpeed={30}
pauseAfterType={1200}
pauseAfterDelete={200}
/>
Slower, more deliberate feel — raise typeSpeed and extend pauseAfterType so readers have time to absorb each word:
<TypingEffect
words={["Designer", "Developer", "Builder"]}
typeSpeed={160}
deleteSpeed={80}
pauseAfterType={3000}
pauseAfterDelete={600}
/>
How to change the words
Edit the words array wherever you use the component. You can have as many strings as you like — the component loops back to the first word when it reaches the end:
<TypingEffect
words={[
"Web Designer",
"Web Developer",
"Astro Developer",
"UI/UX Enthusiast",
"Performance Nerd",
]}
/>
Keep words at a similar length if possible. The width-locking logic sets minWidth to the widest word, so very short words will have visible empty space to their right while the longer words are deleted.
The cursor
The blinking cursor is a <span> rendered immediately after the text span:
<span class="typing-text"></span><span class="typing-cursor" aria-hidden="true">|</span>
It is styled with a 0.75 s step-end blink animation and coloured with the active theme’s brand colour:
.typing-cursor {
display: inline-block;
margin-left: 1px;
animation: blink 0.75s step-end infinite;
color: var(--color-brand-500, currentColor);
font-weight: 300;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
To change the cursor character, open TypingEffect.astro and replace the | inside the cursor span. Common alternatives are ▌ (block cursor) or _ (underscore).
To change the blink speed, adjust the 0.75s value. Faster blinking (0.5s) reads as more urgent; slower (1.2s) is more relaxed.
Accessibility
The component wraps everything in a <span> with an aria-label set to all words joined by a comma:
<span id="typing-abc123" class="typing-effect" aria-label="Web Designer, Web Developer, Astro Developer, Blogger, Coffee lover">
Screen readers announce the full list of words from the aria-label and ignore the animated content inside (the cursor has aria-hidden="true"). The text is therefore both readable and not disruptive to assistive technology.
SEO impact
Because Astro renders the heading on the server, the full element — including the <span aria-label="…"> with all words — is present in the HTML source before any JavaScript runs. Google’s crawler reads the static HTML and indexes all words from the aria-label. The visual animation is a progressive enhancement on top of that static foundation.
How to disable the typing effect
Option 1 — Replace with static text
Remove the <TypingEffect> component and its import, then put your static heading copy directly in the slot:
---
// Remove this line:
// import TypingEffect from '@/components/ui/TypingEffect.astro';
---
<h1 slot="title">
<span class="text-foreground [-webkit-text-fill-color:currentColor]">Astro Rocket —</span><br />
Web Developer
</h1>
Option 2 — Single static word without the cursor
If you want the styled text container but no animation and no cursor, just drop the text inside a plain <span>:
<h1 slot="title">
<span class="text-foreground [-webkit-text-fill-color:currentColor]">Astro Rocket —</span><br />
<span>Web Developer</span>
</h1>
Option 3 — Keep one word with the cursor but stop cycling
Pass an array with a single string. The component will type it once, pause, delete it, and retype it — giving you a “hello, I am typing” feel without ever changing the word. If you also want it to stop after the first type, that requires editing the component logic directly.
Cheat sheet
| Goal | What to change |
|---|---|
| Faster typing | Lower typeSpeed (e.g. 120 → 60) |
| Slower typing | Raise typeSpeed (e.g. 120 → 180) |
| Faster deleting | Lower deleteSpeed (e.g. 70 → 35) |
| Longer pause after typing | Raise pauseAfterType (e.g. 1800 → 3000) |
| Shorter pause between words | Lower pauseAfterDelete (e.g. 400 → 150) |
| Different words | Edit the words array |
| Different cursor character | Replace | in TypingEffect.astro |
| Different cursor colour | Override --color-brand-500 in your theme |
| Remove the effect entirely | Replace <TypingEffect> with a plain <span> |
Five props, one file, zero dependencies — the typing effect is deliberately simple so it stays easy to own. The three forks above are the minimum needed to make it work correctly in a real Astro project with client-side navigation, variable-length words, and a heading with descenders.