---
name: typewriter-effect
description: Use when you want "Techy, simple, familiar" - Displays text one character at a time.
---

# Typewriter Effect

> **Category:** Motion / Interaction  -  **Personality:** Techy, simple, familiar
>
> **Best use:** Hero copy, terminal UI, demos
>
> **Ratings:** Professional 4/5 - Casual 4/5 - Exotic 2/5 - Visual impact 3/5 - Difficulty 2/5 - Performance cost 1/5

## What it is

The typewriter effect reveals text one character at a time — as if someone were typing it live — trailed by a blinking caret. The classic version cycles a *list* of phrases: type a phrase, hold, delete it, then type the next, looping forever. That makes it ideal for a hero headline whose ending keeps changing ("From git push to **production.** / **the global edge.** / …") or a terminal prompt that appears to run command after command. It is built from one self-scheduling `setTimeout` loop that slices a string, so it needs no library. It is distinct from the **Text Scramble** effect: here characters appear strictly in order and are never randomised into junk glyphs.

## Dependencies / CDN

None — pure vanilla JS + CSS. The demo only loads two Google Fonts (Sora for the headline, JetBrains Mono for the terminal); the effect works in any font.

```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Sora:wght@400;600;700;800&display=swap" rel="stylesheet">
```

## HTML

The animated text lives in an empty (or phrase-prefilled) span, immediately followed by a caret span. Mark both `aria-hidden` and expose a static, complete sentence to screen readers.

```html
<!-- Hero headline: a fixed lead-in, then the rotating ending -->
<h2 class="tw-h">From git push to<br>
  <span class="tw-rot">
    <span class="tw-rot-text" aria-hidden="true">production.</span><span class="tw-caret tw-caret--bar" aria-hidden="true"></span>
  </span><span class="tw-sr">production.</span>
</h2>

<!-- Terminal: a fixed prompt, then the rotating command -->
<div class="tw-line">
  <span class="tw-p">&#10140;</span> <span class="tw-cwd">~/app</span> halcyon <span class="tw-term-out" aria-hidden="true">logs --tail app</span><span class="tw-caret tw-caret--block" aria-hidden="true"></span>
</div>
```

## CSS

The whole visible effect is two rules: a caret element plus a keyframe blink. The `is-typing` class (toggled from JS) freezes the caret solid while characters are actually moving — exactly like a real cursor.

```css
.tw-caret{display:inline-block;background:#3ddc97;border-radius:2px;
  animation:tw-blink 1.05s infinite}
.tw-caret.is-typing{animation:none;opacity:1}      /* solid while typing/deleting */

@keyframes tw-blink{0%,50%{opacity:1}51%,100%{opacity:0}}   /* hard 50/50 blink */

/* caret shapes — same element, different proportions */
.tw-caret--bar{width:3px;height:.9em;transform:translateY(.1em);margin-left:7px}  /* text cursor */
.tw-caret--block{width:9px;height:1.02em;transform:translateY(.18em)}             /* terminal */

/* the rotating word, painted as a gradient for the hero */
.tw-rot-text{background:linear-gradient(100deg,#3ddc97,#7cc6ff 78%);
  -webkit-background-clip:text;background-clip:text;color:transparent}
.tw-rot{display:inline-block;min-height:1.12em}    /* reserve a line so it can't collapse */

/* keep the screen-reader copy out of sight but readable */
.tw-sr{position:absolute;width:1px;height:1px;margin:-1px;overflow:hidden;clip:rect(0 0 0 0)}

@media (prefers-reduced-motion: reduce){ .tw-caret{animation:none;opacity:.55} }
```

## JavaScript

One small factory. Pass it the text node, its caret, and an array of phrases; it types, holds, deletes and advances forever. Reduced-motion just writes the first phrase and never starts.

```js
function Typewriter(o){
  var text=o.text, caret=o.caret, phrases=o.phrases;
  var typeSpeed=o.typeSpeed||70, delSpeed=o.delSpeed||34,
      hold=o.hold||1600, gap=o.gap||520, start=o.start||0;
  var p=0, c=0, del=false, timer=null;
  if(text.textContent===phrases[0]){ c=phrases[0].length; del=true; } // start from a prefilled phrase
  function solid(on){ if(caret) caret.classList.toggle('is-typing', on); }
  function jitter(n){ return n + Math.random()*n*0.5; }               // human, uneven cadence
  function tick(){
    var full = phrases[p];
    if(!del){
      text.textContent = full.slice(0, ++c); solid(true);
      if(c===full.length){ del=true; solid(false); return go(hold); } // fully typed -> pause
      return go(jitter(typeSpeed));
    }
    text.textContent = full.slice(0, --c); solid(true);
    if(c===0){ del=false; p=(p+1)%phrases.length; solid(false); return go(gap); } // next phrase
    return go(delSpeed);
  }
  function go(ms){ timer=setTimeout(tick, ms); }
  return { run:function(){ go(start); } };
}

// wire it up
var reduce = window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches;
var heroText = document.querySelector('.tw-rot-text'),
    heroCaret= document.querySelector('.tw-rot .tw-caret');
var phrases = ['production.','the global edge.','live in 9 seconds.','every region.'];

if(reduce){
  heroText.textContent = phrases[0];            // a11y: show full text, no typing
} else {
  Typewriter({text:heroText, caret:heroCaret, phrases:phrases,
    typeSpeed:74, delSpeed:36, hold:1700, gap:520, start:1200}).run();
}
```

## How it works

- **Slice, don't append.** Each tick sets `text.textContent = phrase.slice(0, n)`, incrementing `n` while typing and decrementing while deleting. Re-slicing from the source string (rather than `+=`/`pop`) keeps the displayed text and the index perfectly in sync.
- **Three timing states.** Typing (fast, jittered), a long **hold** the moment a phrase completes, and deleting (a touch faster). When `n` returns to 0 it advances `p = (p+1) % phrases.length`, so it loops indefinitely.
- **Self-scheduling `setTimeout`.** Each tick schedules the next one, so every step can have its own delay and timers never stack up (unlike a fixed `setInterval`).
- **Caret realism.** The caret blinks via a CSS `@keyframes`; toggling `is-typing` freezes it solid while characters move and lets it resume blinking only during the pauses — the small detail that sells it.
- **Jitter.** `base + random·0.5` per keystroke breaks the robotic, perfectly-even rhythm a naive loop produces.

## Customization

- **Speeds & rhythm:** `typeSpeed` / `delSpeed` / `hold` (read time at full phrase) / `gap` (beat before the next phrase) / `start` (stagger when several run at once).
- **Caret shape:** swap `--bar` (slim text cursor) for `--block` (terminal) — only width/height/offset change. Tune the blink period on `animation`.
- **Look of the word:** the demo paints the rotating segment with a `background-clip:text` gradient; drop that for a flat accent colour.
- **Reuse:** the same factory drives a hero headline and a terminal prompt in the live demo — different phrase arrays, different carets, identical engine.

## Accessibility & performance

- **`prefers-reduced-motion`:** the JS checks `matchMedia` and simply writes the full first phrase (no loop); the CSS also stops the caret blink. Users get the message instantly, no motion.
- **Screen readers:** the animated node is `aria-hidden`, and a visually-hidden `.tw-sr` span carries the complete sentence so the line is announced **once**, not letter-by-letter. Never wrap a typewriter in an `aria-live` region — it will spam every keystroke.
- **Cheap:** one `setTimeout` and one `textContent` write per step, no layout thrash if you reserve space. Optionally pause when off-screen (`IntersectionObserver`) or when `document.hidden` to save battery on long pages.

## Gotchas

- **Use `textContent`, not `innerHTML`** — slicing markup mid-tag breaks the DOM, and any user-derived phrase becomes an XSS hole.
- **Unicode:** `String.prototype.slice` cuts by UTF-16 code unit, so emoji and combining marks tear apart. For those phrases type over `Array.from(phrase)` instead of the raw string.
- **Layout shift:** the line width/height changes as it types. Reserve a `min-height` (and `min-width`/`min-ch` for centred text) or the surrounding content will jump on every phrase.
- **Caret on wrap:** if the rotating segment wraps to a new line the caret jumps with it. Keep that segment on its own line (as in the demo) or `white-space:nowrap` it with short phrases.
- **`setInterval` drift:** background tabs throttle timers and a fixed interval can queue overlapping ticks; the self-scheduling `setTimeout` pattern above avoids both.
- **No-JS / pre-hydration flash:** prefill the span with phrase 0 (the engine detects it and begins by deleting) so there is no empty blank before the script runs.
