CSS Typewriter Effect

Text typed letter by letter using a @keyframes width animation with steps() timing.

Published June 20, 2026 Intermediate 8 min read

A CSS typewriter effect animates an element's width from zero to the width of its text while overflow: hidden and white-space: nowrap clip whatever has not been revealed yet. The letters never move. What moves is the right hand edge of the box, and a blinking border-right sitting on that edge reads as a cursor.

The part that makes it look typed rather than wiped is steps(). A normal easing function slides the width smoothly and reveals half a letter at a time. steps(18) advances in eighteen discrete jumps, so a character appears whole or not at all. The count in steps() and the ch value it animates to both have to match the text, and getting either one wrong is the single most common way this effect breaks.

Three lines in sequence

Hello, World! 👋

Pure CSS only

// no JavaScript required

HTML

<div class="tw-stage">
  <p class="tw-line tw-line-1">Hello, World! 👋</p>
  <p class="tw-line tw-line-2">Pure CSS only</p>
  <p class="tw-line tw-line-3">// no JavaScript required</p>
</div>

CSS

.tw-stage {
  display: flex;
  flex-direction: column;
  gap: 1.25rem;
  padding: .5rem;
}

/* A monospace font is what makes the ch unit line up with characters. */
.tw-line {
  overflow: hidden;
  white-space: nowrap;
  width: 0;
  margin: 0;
  font-family: "DM Mono", "Fira Code", Consolas, monospace;
  border-right: 2px solid #b8ff57;
}

/* Each line's steps() count and target ch width match its own text length.
   "// no JavaScript required" is 25 characters, so line 3 uses 25. */
.tw-line-1 {
  font-size: 1.5rem;
  font-weight: 600;
  color: #f0f0f0;
  animation:
    tw-type-1 1.8s steps(18) forwards,
    tw-cursor .7s step-end infinite;
}

.tw-line-2 {
  font-size: 1rem;
  color: #b8ff57;
  border-right-color: #57d9a3;
  animation:
    tw-type-2 1.2s steps(14) 2s forwards,
    tw-cursor .7s step-end 2s infinite;
}

.tw-line-3 {
  font-size: .85rem;
  color: #88888f;
  border-right-color: #88888f;
  animation:
    tw-type-3 1s steps(25) 3.4s forwards,
    tw-cursor .7s step-end 3.4s infinite;
}

@keyframes tw-type-1 { to { width: 18ch; } }
@keyframes tw-type-2 { to { width: 14ch; } }
@keyframes tw-type-3 { to { width: 25ch; } }

@keyframes tw-cursor {
  50% { border-right-color: transparent; }
}

Other ways to build it

Type, pause, delete, repeat

Four keyframes turn a one shot reveal into a loop: zero width, full width, full width again after a pause, then zero. The detail that makes it work is that a timing function applies to every keyframe segment separately rather than to the animation as a whole, so steps(23) governs the typing and the deleting independently and both run at the same character rate. The two segments where the width does not change still get their twenty three steps, and produce nothing visible, which is exactly the pause. The text is twenty three characters including the spaces and the full stop.

This types and deletes.

HTML

<p class="tw-loop">This types and deletes.</p>

CSS

/* uses @keyframes tw-cursor from the main example above; the rules here are
   what changes, not the whole file */
/* "This types and deletes." is 23 characters, spaces and stop included */
.tw-loop {
  overflow: hidden;
  white-space: nowrap;
  /* without this the 2px cursor border comes out of the 23ch and
     the last glyph loses its final two pixels */
  box-sizing: content-box;
  width: 0;
  font-family: "DM Mono", Consolas, monospace;
  font-size: 1.1rem;
  color: #b8ff57;
  border-right: 2px solid #b8ff57;
  animation:
    tw-loop-type 6s steps(23) infinite,
    tw-cursor .7s step-end infinite;
}

/* the timing function applies to each segment on its own, so the two
   held segments are the pause and the other two run at one step each */
@keyframes tw-loop-type {
  0%   { width: 0; }
  35%  { width: 23ch; }
  65%  { width: 23ch; }
  90%  { width: 0; }
  100% { width: 0; }
}

@media (prefers-reduced-motion: reduce) {
  .tw-loop {
    animation: none;
    width: 23ch;
  }
}

Block cursor, and steps starting at the front

Two changes here. The caret is a separate empty element sitting after the clipped text rather than a border on it, which means it can be a solid block, sized in ch, and given its own height. Because it is the next thing in the line box, it still travels with the growing width for free. The second change is steps(12, start): the default end holds everything back for the first twelfth of the duration, while start puts the first character on screen immediately. The modern spelling is jump-start, and start is still a valid alias.

Block cursor

HTML

<p class="tw-block-line">
  <span class="tw-block-text">Block cursor</span><span class="tw-block-caret"></span>
</p>

CSS

/* "Block cursor" is 12 characters */
.tw-block-text {
  display: inline-block;
  overflow: hidden;
  white-space: nowrap;
  vertical-align: bottom;
  width: 0;
  /* start, not end, so the first character is there immediately */
  animation: tw-block-type 1.6s steps(12, start) forwards;
}

/* a sibling, so it follows the clipped box as that box grows */
.tw-block-caret {
  display: inline-block;
  width: .55ch;
  height: 1.1em;
  vertical-align: bottom;
  background: #b8ff57;
  animation: tw-block-blink .7s step-end infinite;
}

@keyframes tw-block-type { to { width: 12ch; } }

@keyframes tw-block-blink { 50% { background: transparent; } }

How it works

The typewriter illusion works by animating width from 0 to the full text length (Nch, where N is the character count) combined with overflow: hidden and white-space: nowrap. The steps(N) timing function, where N matches the character count, advances the animation in discrete jumps instead of smoothly, making each character appear one at a time. A blinking border-right acts as the cursor.

The ch unit is the width of the 0 glyph in the current font. In a monospace face every glyph is that width, so 25ch really is twenty five characters and the arithmetic works. In a proportional face it does not: 25ch of Helvetica is nowhere near twenty five characters of Helvetica, and the animation either stops with the line half revealed or runs on past the end into empty space with the cursor stranded out to the right. A monospace font is not a stylistic choice here, it is what makes the unit mean anything.

The steps() count and the target ch width are two separate numbers that both describe the same thing, and nothing enforces that they agree. Set steps(20) with a target of 25ch and the text still finishes, in twenty jumps of one and a quarter characters each, so letters appear in ragged pairs. Set the target to 20ch with steps(25) and the line simply stops five characters short, cut off mid word, which is exactly the failure that shipped on this page's third line before it was corrected to 25.

Counting is less obvious than it looks. Spaces and punctuation are characters and have to be included. An emoji is not one ch wide, so the first line here is fourteen characters plus one emoji and animates to 18ch in eighteen steps, because the emoji takes up roughly four characters of width on its own. Any time a line contains something that is not a plain ASCII glyph, measure the rendered width rather than counting letters.

steps(N) defaults to steps(N, end), which means the change happens at the end of each interval, so nothing at all is visible for the first 1/N of the duration and the final character lands exactly as the animation finishes. steps(N, start) moves the jump to the beginning of each interval, so the first character appears immediately and the last one arrives one interval early. start is what you want when the line follows another line and the pause between them is already long enough.

The cursor is a border-right blinked by a second animation using step-end, which switches the color at the halfway keyframe with no fade. Both animations are listed in one animation shorthand separated by a comma, and the typing one carries forwards so the element keeps its final width instead of snapping back to zero. Delays stagger the three lines, and each line's cursor delay matches its typing delay so no cursor blinks before its line starts.

CSS properties used

width
The property actually being animated. From 0 to the text's width in ch, with everything past the edge clipped.
overflow
hidden is what hides the untyped part of the line. Without it the whole string is visible from the first frame.
white-space
nowrap keeps the line on one row. A line that wraps has no single width to animate and the effect falls apart.
animation-timing-function
steps(N) advances in N discrete jumps so characters appear whole. step-end on the cursor switches its color with no fade.
border-right
The cursor. A 2px solid edge on the element's right side, which travels with the animated width for free.
animation-fill-mode
forwards holds the final width. Without it the line collapses back to zero the instant the animation ends.

Browser support

FeatureChromeFirefoxSafariEdge
animation455.112
ch unit272712
transition455.112
prefers-reduced-motion746310.179

Everything the effect needs is old enough to be uncontroversial. Can I Use has no separate entry for the steps() timing function or for its start and end position keywords, so they are not in the table: they arrived with CSS animations and have been available for as long as animations have. The newer jump-start, jump-end, jump-both and jump-none keywords from the easing specification say the same things more precisely, and start and end remain valid aliases for the two that matter here.

Accessibility notes

The text is real text throughout. A screen reader reads the whole line immediately, because clipping with overflow: hidden changes what is painted and not what is in the accessibility tree. That is usually the right outcome, and it does mean the effect is decorative: nobody using assistive technology experiences the reveal, so nothing important should depend on it.

Text appearing character by character is motion, and for some readers it is a hard one, because the content changes while they are trying to read it. A prefers-reduced-motion block should set animation: none and give the element its final width, so the full line is simply present. Removing the animation without setting the width leaves an element 0 pixels wide and no text at all, which is worse than the animation was.

The blinking cursor is the other half of the same problem. A step-end blink at 0.7 seconds is a little over one flash per second, comfortably under the three per second threshold, but it never stops. Ending the blink after the line finishes, with animation-iteration-count on the cursor, removes a permanently moving element from the page for the cost of one number.

What you can build with it

  • Terminal and developer landing pages. A command typing itself out above the fold, which is what the monospace requirement suits anyway.
  • Rotating value propositions. One sentence that types, deletes and retypes with a different ending, which is the first variant below.
  • Chat and message mockups. A reply appearing as though someone is writing it, with the delay between lines doing the work of the pauses.
  • Loading and status messages. Short progress lines that type themselves, often alongside the glitch effect for the same terminal aesthetic.
  • Code walkthroughs. A snippet revealed line by line, using a staggered animation-delay per line rather than one long animation.

Mistakes worth avoiding

  • A steps() count that does not match the target ch width. Too few steps and characters appear in ragged groups. Too many and the line stops short, cut off mid word.
  • Using a proportional font. ch is the width of the zero glyph, so the number stops corresponding to characters and the cursor ends up stranded away from the text.
  • Forgetting that spaces and punctuation count. A miscount of two is enough to leave a full stop hanging outside the animation or clipped off the end.
  • Leaving animation-fill-mode at its default. The line finishes typing and instantly collapses back to nothing.
  • Ignoring box-sizing when the cursor is a border. Under the usual border-box reset a 2px border-right is taken out of the declared width, so a line set to exactly Nch clips the last two pixels off its final glyph. Add the border width to the target, or set box-sizing: content-box on that element.
  • Removing the animation under prefers-reduced-motion without also setting the width. The element stays at 0 and the text never appears at all.

Frequently asked questions

How do you make a typewriter effect in CSS?
Give the element overflow: hidden, white-space: nowrap and width: 0, then animate its width to the text length in ch using steps(N) where N is the character count. Add a border-right and a second animation to blink it, and set forwards so the finished line holds its width.
Why does my typewriter animation cut off the text?
The target width is smaller than the text. Count the characters again, including spaces and punctuation, and make the ch value and the steps() count the same number. If the line contains an emoji or a non ASCII glyph, it is wider than one ch and the total has to be measured rather than counted.
Does the typewriter effect need a monospace font?
In this form, yes. The whole thing rests on ch meaning one character wide, and that is only true when every glyph is the same width. With a proportional font the animation ends in the wrong place and the cursor sits away from the last letter.
What does steps() actually do?
It replaces smooth interpolation with a fixed number of discrete jumps. steps(18) divides the animation into eighteen equal intervals and holds the value steady within each one, which is what makes a character appear whole rather than sliding into view a few pixels at a time.
How do you loop a typewriter that deletes itself?
Use one animation with four keyframes: zero width, full width, full width again after a pause, then back to zero. Because a timing function applies to every segment separately, the same steps() count governs the typing and the deleting, and the two hold segments produce no visible change. The first variant below does this.