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 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.
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.
<span class="tw-type">Build succeeded.</span>
.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 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, 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 Nch 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 inemso 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
1chwide 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.
<span class="tw-loop"><span class="tw-loop-text">Now you see it</span></span>
.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.
<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>
.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, 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:
@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 (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:
<p>We build <span class="typed" aria-hidden="true"></span>
<span class="visually-hidden">fast sites, accessible forms and clean CSS.</span></p>
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; animating width triggers layout, which is fine for one small line but not something to scale up to whole paragraphs.