---
title: "CSS Typewriter Effect: Pure CSS Typing Animation"
description: "Build a pure CSS typewriter effect with steps() and ch: blinking caret, type-and-delete loop, multiple lines, monospace caveats and reduced motion."
url: https://frontendhelpers.com/css-typewriter-effect
updated: 2026-10-12
---

# CSS typewriter effect: pure CSS typing

Text that types itself out, letter by letter, with a blinking caret — built from steps(), the ch unit and two keyframes, and honest about where it breaks.

CSS recipe · By [AurumCodeWorks](https://frontendhelpers.com/about) · 7 min read · Updated 12 October 2026

## How the trick works

A CSS typewriter doesn't add letters at all. The whole sentence is in the HTML from the start; CSS hides most of it and then uncovers it one character at a time. Three declarations do the hiding: `white-space: nowrap` keeps the text on a single line, `overflow: hidden` clips whatever sits outside the box, and a `width` measured in `ch` sets how much of the line is visible. Animate that width from `0` to the full length and the text appears to be typed.

A plain width animation would slide smoothly and reveal half-letters. The typing feel comes from the `steps()` easing function, which splits the animation into equal jumps instead of a smooth curve. [MDN's steps() reference](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/easing-function/steps) describes the integer as the number of equal intervals and notes that the jump position defaults to `end`, so `steps(16)` means sixteen jumps, with the final one landing as the animation finishes. Make the number of steps equal the number of characters and each jump reveals exactly one more letter.

All three demos below are plain CSS; the page’s only script restarts or pauses them.

Build succeeded.

**Type once** — 16 characters, steps(16), then the caret blinks four times and settles.

Now you see it

**Type and delete** — one keyframe set types, holds, erases and waits, forever.

$ npm create site Installing... Done in 2.4s

**Multiple lines** — each line waits for the one above, and everything finishes inside five seconds.

## Recipe 1: type once, with a blinking caret

This is the version most people want for a hero line or a terminal-style heading. The caret is the element's own `border-right`. Because a border sits outside the content box, `overflow: hidden` never clips it, so it always rides along at the edge of the revealed text.

```html
<span class="tw-type">Build succeeded.</span>
```

```css
.tw-type {
  font-family: ui-monospace, Menlo, Consolas, monospace;
  display: inline-block;
  width: 16ch;
  overflow: hidden;
  white-space: nowrap;
  border-right: 2px solid currentColor;
  animation:
    tw-type 1.6s steps(16) both,
    tw-blink 0.75s step-end 1.6s 4 forwards;
}
@keyframes tw-type { from { width: 0; } }
@keyframes tw-blink {
  from, to { border-right-color: currentColor; }
  50%      { border-right-color: transparent; }
}
```

A few details are deliberate. The `tw-type` keyframes only declare `from`; the browser uses the element's own `width: 16ch` as the end point, so you change the length in one place. `display: inline-block` is needed because width has no effect on a plain inline span. The caret stays solid while typing, as a real cursor does, then blinks four times starting at 1.6s and stops on the visible frame thanks to `forwards`. In a centred container, give the line a fixed-width wrapper (the demo cards do), or it drifts sideways as it grows. A caret that blinks forever is moving content that never ends; more on why that matters below.

Our [typewriter card in the text animation library](https://frontendhelpers.com/css-text-animations#anim-typewriter) is the same idea in its shortest form, with an infinite caret, if you need a quick copy-paste version.

## Getting the numbers right

The rule is simple: **steps = characters = width in ch**. Count every character, including spaces and punctuation. Duration is then a choice of speed: multiply the count by a per-character time. Around 100ms per character feels like deliberate typing; 60ms feels brisk.

| Text | Characters | steps() | Final width | At 100ms/char | At 60ms/char |
| --- | --- | --- | --- | --- | --- |
| Hello, world. | 13  | steps(13) | 13ch | 1.3s | 0.78s |
| Done in 2.4s | 12  | steps(12) | 12ch | 1.2s | 0.72s |
| Now you see it | 14  | steps(14) | 14ch | 1.4s | 0.84s |
| Build succeeded. | 16  | steps(16) | 16ch | 1.6s | 0.96s |
| $ npm create site | 17  | steps(17) | 17ch | 1.7s | 1.02s |
| The quick brown fox jumps over the lazy dog | 43  | steps(43) | 43ch | 4.3s | 2.58s |

The last row is the warning. A 43-character sentence takes over four seconds at a natural pace, and nobody waits that long to read a headline. Keep typed text short, or speed it up. If your steps are fewer than your characters, the text jumps two letters at a time; if they are more, some steps reveal nothing and the rhythm stutters. Off-by-one errors are the usual cause of a final letter that never quite appears.

If you add `letter-spacing`, each character becomes wider than `1ch`, so the final width must include it: `calc(16 * (1ch + 0.05em))` for `letter-spacing: 0.05em`.

## Why ch only lines up in monospace fonts

According to [MDN's <length> reference](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/length), `1ch` is the advance measure of the `0` glyph in the element's font. In a monospace font every character shares that advance, so _n_ steps of `1ch` land exactly on _n_ letter boundaries. In a proportional font like the one this paragraph uses, an `i` is much narrower than a `0` and an `m` is much wider. The steps drift away from the letters: some frames slice a glyph in half, others reveal two at once, and the final width of _N_ch may clip the end of the sentence or leave a gap before the caret.

There is no CSS-only fix for that, so the honest options are:

-   **Use a monospace font for the typed text.** The demos do this, and a typewriter look is monospace by nature anyway.
-   **Accept an approximation.** Keep `steps(N)` but set the final width to the measured width of the text in `em` so nothing is clipped. Intermediate frames will occasionally cut a letter. At 60–100ms a frame, many people won't notice; check it at a large font size before shipping.
-   **Reveal each letter separately.** Wrap every character in a span and stagger their appearance, usually with JavaScript. This is exact in any font, including emoji and characters from other scripts, which are not `1ch` wide even in monospace fonts.

## Recipe 2: typing, pausing, deleting, looping

Rotating taglines usually type a phrase, hold it, erase it and start again. One keyframe set can do all four. The key fact is that a stepped timing function applies to each segment between keyframes, not to the whole animation, so `steps(14)` gives 14 jumps on the way up and another 14 on the way down, while the flat segments in between do nothing.

```html
<span class="tw-loop"><span class="tw-loop-text">Now you see it</span></span>
```

```css
.tw-loop {
  font-family: ui-monospace, Menlo, Consolas, monospace;
  display: inline-flex;
  align-items: center;
}
.tw-loop-text {
  width: 14ch;
  overflow: hidden;
  white-space: nowrap;
  animation: tw-type-delete 5s steps(14) infinite;
}
.tw-loop::after {
  content: "";
  width: 0.55ch;
  height: 1.15em;
  margin-left: 1px;
  background: currentColor;
  animation: tw-caret-fade 0.75s step-end infinite;
}
@keyframes tw-type-delete {
  0%        { width: 0; }
  40%, 60%  { width: 14ch; }
  90%, 100% { width: 0; }
}
@keyframes tw-caret-fade { 50% { opacity: 0; } }
.tw-paused .tw-loop-text,
.tw-paused .tw-loop::after { animation-play-state: paused; }
```

In a 5-second cycle that works out to two seconds of typing (about 143ms a letter), a one-second hold, a faster 1.5-second delete (about 107ms a letter) and half a second of empty line. This version also shows the other caret technique: a block caret drawn by `::after` on a wrapper. It has to live on the wrapper, because a pseudo-element inside the clipped element would be clipped with the text. An empty `content: ""` is ignored by screen readers.

CSS alone can only loop one phrase. Cycling several different phrases needs a separate element per phrase or a script, which is where the JavaScript version below earns its place.

## Recipe 3: multiple lines in sequence

A line can't wrap while it is being typed, because `white-space: nowrap` is what keeps the clipping working. (`white-space` is now a shorthand, and `nowrap` actually sets its `text-wrap-mode` longhand; the old spelling still works everywhere.) So each line is its own element with its own count, and later lines wait their turn with `animation-delay`. Custom properties keep the arithmetic in one place: `--n` drives the width, the step count and the duration at 60ms a character.

```html
<div class="tw-term">
  <span class="tw-line tw-l1">$ npm create site</span>
  <span class="tw-line tw-l2">Installing...</span>
  <span class="tw-line tw-l3">Done in 2.4s</span>
</div>
```

```css
.tw-term { font-family: ui-monospace, Menlo, Consolas, monospace; }
.tw-line {
  display: block;
  width: calc(var(--n) * 1ch);
  overflow: hidden;
  white-space: nowrap;
  border-right: 2px solid transparent;
  animation:
    tw-type calc(var(--n) * 60ms) steps(var(--n)) var(--delay) both,
    tw-caret-on calc(var(--n) * 60ms) linear var(--delay);
}
.tw-l1 { --n: 17; --delay: 0s; }
.tw-l2 { --n: 13; --delay: 1.27s; }
.tw-l3 {
  --n: 12;
  --delay: 2.3s;
  animation:
    tw-type calc(var(--n) * 60ms) steps(var(--n)) var(--delay) both,
    tw-caret-on calc(var(--n) * 60ms) linear var(--delay),
    tw-blink 0.75s step-end 3.02s 2 forwards;
}
@keyframes tw-caret-on { from, to { border-right-color: currentColor; } }
```

Each delay is the previous delay plus that line's duration plus a 0.25s pause: line two starts at 0 + 1.02 + 0.25 = 1.27s, line three at 1.27 + 0.78 + 0.25 = 2.3s, and line three finishes at 3.02s. `both` matters here: the backwards fill holds lines two and three at zero width during their delay, so they don't flash up fully before their turn. The caret is transparent by default and the short `tw-caret-on` animation shows it only on the line currently typing; the last line then blinks twice and stays solid. The `tw-type` and `tw-blink` keyframes are the ones from recipe 1.

## Keep it real text, and respect reduced motion

Because the words are in the DOM from the first frame, clipping them doesn't hide them from assistive technology. A screen reader announces the full sentence at once rather than letter by letter, which is exactly what you want. Search engines read it, and people can still select and copy it. Don't undo that by inserting characters with a script into a live region, or by splitting text into spans without providing the complete string to screen readers.

Sighted readers, on the other hand, have to wait. Never type out anything essential: form instructions, error messages, prices or the main call to action. Use the effect for flavour around content that is already readable elsewhere.

Two requirements are easy to miss. First, honour the [prefers-reduced-motion media feature](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@media/prefers-reduced-motion), which tells you that someone has asked their operating system to minimise non-essential motion. The demos switch every animation off, and because each element's base width is already the full width, the finished text simply appears:

```css
@media (prefers-reduced-motion: reduce) {
  .tw-type,
  .tw-loop-text,
  .tw-loop::after,
  .tw-line { animation: none; }
}
```

Second, [WCAG 2.2.2 Pause, Stop, Hide](https://www.w3.org/WAI/WCAG22/Understanding/pause-stop-hide.html) (Level A) requires a way to pause, stop or hide moving or blinking content that starts automatically, lasts more than five seconds and sits alongside other content. That is why recipes 1 and 3 stop within five seconds, and why the looping demo has a Pause button that adds a class setting `animation-play-state: paused`. An infinite caret blink falls under the same rule.

## When JavaScript is the better tool

The CSS technique is ideal for one short, fixed string in a monospace font. Reach for a few lines of JavaScript when the text varies (user names, translated strings, content from a CMS), when you are cycling through several phrases, or when the typed text must use a proportional font. No library is needed:

```html
<p>We build <span class="typed" aria-hidden="true"></span>
  <span class="visually-hidden">fast sites, accessible forms and clean CSS.</span></p>
```

```js
const el = document.querySelector('.typed');
const phrases = ['fast sites.', 'accessible forms.', 'clean CSS.'];
const wait = ms => new Promise(r => setTimeout(r, ms));

async function run() {
  if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
    el.textContent = phrases[0];
    return;
  }
  for (let i = 0; ; i = (i + 1) % phrases.length) {
    const chars = [...phrases[i]];
    for (let n = 1; n <= chars.length; n++) {
      el.textContent = chars.slice(0, n).join('');
      await wait(80);
    }
    await wait(1500);
    for (let n = chars.length - 1; n >= 0; n--) {
      el.textContent = chars.slice(0, n).join('');
      await wait(40);
    }
    await wait(300);
  }
}
run();
```

Spreading the string with `[...phrases[i]]` splits it by code point rather than UTF-16 unit, so a simple emoji is never cut in half; for joined sequences such as flags or family emoji, split with `Intl.Segmenter` instead. The changing text is hidden from screen readers with `aria-hidden`, and the complete sentence sits in a visually hidden span, so assistive technology gets the content once instead of hearing it rebuilt every 80ms. Because it loops, it still needs a pause control under WCAG 2.2.2. It also works in any font, because the browser lays out real letters. For animation costs in general, see our guide to [CSS animation performance](https://frontendhelpers.com/css-animation-performance); animating `width` triggers layout, which is fine for one small line but not something to scale up to whole paragraphs.

## Frequently asked questions

### How do I make a typewriter effect with only CSS?

Put the text in an element with white-space: nowrap and overflow: hidden, set its width in ch to the number of characters, and animate the width from 0 using steps(N), where N is the character count. A border-right that blinks with a step-end animation acts as the caret. Use a monospace font so each step reveals exactly one letter.

### Why does my CSS typewriter cut letters in half?

The ch unit is the width of the 0 glyph, so it only matches every letter in a monospace font. In a proportional font, narrow letters like i and wide ones like m drift away from the step boundaries. Switch to a monospace font, or reveal per-letter spans with JavaScript. Letter-spacing also needs adding to each step.

### How do I make a CSS typewriter effect on multiple lines?

Make each line its own element with its own character count, width and steps() value, then give each line an animation-delay equal to the total duration of the lines before it plus a short pause. Use animation-fill-mode: both so later lines stay hidden until their turn.

### Is a typewriter animation accessible?

It can be. Keep the full text in the DOM so screen readers read it straight away, never type out essential instructions, show the finished text when prefers-reduced-motion is set to reduce, and give any loop that runs longer than five seconds a pause control.

## Keep going

- [Typewriter snippet](https://frontendhelpers.com/css-text-animations#anim-typewriter): The one-line copy-paste version with steps(13) and a blinking caret.
 
- [CSS text animations](https://frontendhelpers.com/css-text-animations): Gradient flows, reveals and other effects for headings and copy.
 
- [CSS animation performance](https://frontendhelpers.com/css-animation-performance): Which properties are cheap to animate and how to diagnose jank.
