Skip to content
H Hans Martens
astro-rocket components customization tutorial javascript

The Hero Typing Effect in Astro Rocket — How It Works and How to Tune It

Astro Rocket's hero headline cycles through words with a typing animation. Learn how it works, how to tune every speed and pause, and how to disable it entirely.

H

Hans Martens

3 min read

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

PropDefaultWhat it controls
words(required)Array of strings to cycle through
typeSpeed120Milliseconds between each character typed
deleteSpeed70Milliseconds between each character deleted
pauseAfterType1800Pause in ms after the word is fully typed
pauseAfterDelete400Pause 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

GoalWhat to change
Faster typingLower typeSpeed (e.g. 12060)
Slower typingRaise typeSpeed (e.g. 120180)
Faster deletingLower deleteSpeed (e.g. 7035)
Longer pause after typingRaise pauseAfterType (e.g. 18003000)
Shorter pause between wordsLower pauseAfterDelete (e.g. 400150)
Different wordsEdit the words array
Different cursor characterReplace | in TypingEffect.astro
Different cursor colourOverride --color-brand-500 in your theme
Remove the effect entirelyReplace <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.

Back to Blog
Share:

Related Posts

Animations in Astro Rocket — Every Effect Explained

A complete breakdown of every animation built into Astro Rocket — page transitions, scroll-triggered counters, the reactive header, card hovers, and the full micro-animation library.

H Hans Martens
2 min read
astro-rocket animations components customization css

How Astro Rocket's Design System Works — Tokens, Colors, and Dark Mode

Astro Rocket uses a three-tier token architecture with OKLCH colors. Change one value and the entire site updates. Here's how it works and how to make it yours.

H Hans Martens
2 min read
astro-rocket design-system tailwind customization tutorial

57 Components Ready to Use — Astro Rocket's Full UI Library

Astro Rocket ships with 57 production-ready components from the Velocity library — buttons, cards, dialogs, forms, data display, and full page-structure components. All accessible, all themed.

H Hans Martens
2 min read
astro-rocket components ui velocity

Follow along

Stay in the loop — new articles, thoughts, and updates.