For a single line in a monospace font, CSS can do this on its own: animate the element's width from zero to the length of the text in ch units, use steps() with the same number so it grows one character at a time, and let a right border be the cursor. Copy the first block below. If your font isn't monospace, the text needs to wrap, or someone else will be editing the words, skip to the per-letter version.
The copy-paste version
<p class="typing">Ship it before the coffee cools.</p> .typing { width: 32ch; /* characters in the line, spaces included */ white-space: nowrap; overflow: hidden; font-family: ui-monospace, Menlo, Consolas, monospace; border-right: 0.1em solid; /* the cursor */ animation: typing 3.2s steps(32), blink 0.75s step-end 6; } @keyframes typing { from { width: 0 } } @keyframes blink { 50% { border-color: transparent } } @media (prefers-reduced-motion: reduce) { .typing { animation: none; } } When you change the text, change two numbers. width and steps() both have to equal the character count, and spaces and punctuation count too. "Ship it before the coffee cools." is 32 characters, so it's 32ch and steps(32). The duration is up to you; 100 ms per character (3.2s here) reads as quick but human.
How steps() makes it type
Without steps() the width would grow smoothly and you'd see letters slide in half-cut. steps(32) splits the 3.2 seconds into 32 equal jumps, and because every glyph in a monospace font is exactly 1ch wide (ch is the width of the "0" glyph), each jump uncovers exactly one character.
The second argument of steps() decides where each jump lands. It defaults to end, so nothing shows for the first 100 ms and the last character appears right as the animation ends. steps(32, jump-start) shows the first letter immediately, which feels snappier if the line is the first thing on the page. The cursor uses step-end for the same reason: the border switches between visible and transparent instead of fading.
Why ch and not width: 100%
The CSS-Tricks snippet from 2016, still the top result for this search, animates to width: 100%. A percentage width is a share of the parent, so in an ordinary block container 100% means the whole column, and the cursor keeps travelling long after the text has ended. When I ran it in a normal page, the cursor stopped about 600px to the right of the full stop. It works in their demo because the container there is sized by the text (their notes say the demo relies on flexbox), so 100% of the parent happens to equal the text. Animating to 32ch doesn't depend on the parent at all, which is why I'd use it.
What goes wrong with the classic version
Swap the font for anything proportional and it breaks in two ways at once. A "W" is far wider than an "i", so the 1ch jumps no longer line up with letters and you get characters cut through the middle while it types. And 32ch is no longer the width of the text: with Georgia, the same code finished with the cursor parked about 180px past the end of the sentence.
On phones, the same white-space: nowrap that keeps the reveal on one line means the line can never wrap. At 28px that sentence is about 540px wide; on a 375px phone the end of it is simply cut off. You can shrink the font with a media query, but a longer headline will hit the same wall.
And it doesn't survive editing. The moment a client edits the headline in a CMS, the character count is wrong and the cursor either stops early or overshoots. If anyone other than you will touch the words, use the next version.
Any font, any length, any number of lines
Instead of uncovering a box, put every letter in its own span, hide them all with visibility: hidden, and reveal each one after a delay of its index times the speed. Hidden letters still take up their space, so the browser lays out the finished text before anything appears: lines wrap where they normally would, and nothing jumps when the last word lands. There's nothing to count and no font restriction.
<h1 class="type">Any font, any length, and it wraps onto as many lines as it needs.</h1> .type { --speed: 55ms; } .type .ch { position: relative; visibility: hidden; animation: show 0s calc(var(--i) * var(--speed)) forwards; } .type .ch::after { /* the cursor, shown on the newest letter */ content: ""; position: absolute; top: 0.15em; bottom: 0.1em; right: -0.06em; width: 0.06em; background: currentColor; opacity: 0; animation: caret var(--speed) calc(var(--i) * var(--speed)); } .type .ch:last-child::after { animation: blink 1s calc(var(--i) * var(--speed)) step-end 4; } @keyframes show { to { visibility: visible } } @keyframes caret { from, to { opacity: 1 } } @keyframes blink { 0%, 50% { opacity: 1 } 50.01%, 100% { opacity: 0 } } @media (prefers-reduced-motion: reduce) { .type .ch, .type .ch::after { animation: none; visibility: visible; } } .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } The cursor is a thin ::after bar on each letter that's only visible during that letter's own slot, so it always sits after the newest character, including at the start of a new line. The last letter keeps it and blinks four times.
Something has to write those spans, and for that I use a short script. It doesn't animate anything; it splits the text once, keeps a full copy for screen readers, and hands the timing back to CSS. If the script never runs, the heading just shows as normal text.
for (const el of document.querySelectorAll(".type")) { const text = el.textContent.trim().replace(/\s+/g, " "); const label = document.createElement("span"); label.className = "sr-only"; label.textContent = text; const shown = document.createElement("span"); shown.setAttribute("aria-hidden", "true"); [...text].forEach((char, i) => { const s = document.createElement("span"); s.className = "ch"; s.style.setProperty("--i", i); s.textContent = char; shown.append(s); }); el.replaceChildren(label, shown); } Dropping the script with sibling-index()
If your templates already output one span per letter (a static site generator can do this at build time), CSS can number them itself. sibling-index() returns an element's position among its siblings and, according to MDN, has worked in all major browsers since August 2026. Registering --i as an integer makes it resolve on the letter and pass the number down to the cursor. Replace the inline --i with this:
@property --i { syntax: "<integer>"; inherits: true; initial-value: 0; } .type .ch { --i: calc(sibling-index() - 1); } I tested this in Chrome only. Older browsers ignore it, so the letters simply appear together, which is an acceptable fallback.
Screen readers and reduced motion
The classic version is easy here: the text is in the page the whole time, just clipped, so a screen reader reads the full line whatever the animation is doing. The per-letter version isn't. Some screen readers announce a run of single-letter spans as separate letters, which is why the script hides the animated copy with aria-hidden="true" and adds a visually hidden copy of the sentence. I used a hidden span rather than aria-label because ARIA doesn't allow naming plain p and span elements, and a hidden span works on any element. In Chrome's accessibility tree the heading above comes out as the single sentence.
Both versions include a prefers-reduced-motion block that shows the finished text straight away. Notice too that neither cursor blinks forever. WCAG 2.2.2 asks for a way to pause anything that moves or blinks for more than five seconds next to other content, and a cursor blinking under your headline counts. Giving the blink an iteration count (6 × 0.75s, 4 × 1s) keeps it under five seconds, which is simpler than adding a pause button. The same rule applies to the typing itself, so a paragraph that takes ten seconds to type out is a problem twice over.
When to stop using CSS
Once you want words that type, delete themselves and get replaced ("We build websites / apps / stores"), CSS can still do it: Temani Afif's version stacks the phrases in a grid and slides between them. But the phrase count is baked into three linked numbers, and you can't hold one word longer than another. A short script is easier to change; the JavaScript typing animation guide has a tested version with backspace, plus what typed.js and TypeIt cost for commercial sites now.
And there are places where neither runs. Can I Email estimates that only about a third of email clients support @keyframes (their data was last tested in 2021). GitHub READMEs strip styles, and Notion pages and Google Slides have no CSS at all. In those places the same effect works as an image. The typing text GIF maker renders this exact animation, cursor included, as a GIF or animated PNG, so the effect looks the same in an email signature as it does on your site.
Originally published at typingtextgif.com. The GIF renderer mentioned above is open source: fancydirty/typing-text-gif.
