CSS Typewriter Animation: Steps(), Blinking Cursor, Erase Loop
Build a pure-CSS typewriter effect with steps(), a blinking cursor, and an auto-erase loop — no JavaScript required. Full working code inside.
Why steps() Changes Everything
Most CSS animations use a continuous easing curve — ease, linear, cubic-bezier. The element moves smoothly from point A to point B. That's completely wrong for a typewriter. You want discrete jumps: one character at a time, no in-between states. That's exactly what steps() gives you.
The steps(N, end) timing function divides an animation into N equal, instantaneous jumps. If your word has 12 characters, you pass steps(12) and the animation jumps 12 times over its duration. Each jump reveals one more character. No interpolation, no blur, no tweening — just a hard cut. It's the animation equivalent of a film projector clicking through frames.
Honestly, this is one of those CSS features that feels like a hack until you actually understand the model. The width property does the real work here: you animate width from 0 to the full text width, and overflow: hidden clips everything that hasn't been "typed" yet. The steps() function just makes that width expansion happen in choppy increments instead of a smooth slide.
One more thing — steps() accepts a second argument: start or end. end (the default) means the jump happens at the *end* of each interval, so the first frame shows zero characters. start means the jump happens at the *beginning*, showing one character immediately. For typewriters, end is almost always what you want.
The Core Typewriter Effect
Here's the simplest working version. No frameworks, no JavaScript — just two keyframe rules and about 15 lines of CSS.
.typewriter {
font-family: 'Courier New', monospace;
font-size: 1.5rem;
white-space: nowrap;
overflow: hidden;
width: 0;
border-right: 2px solid currentColor; /* the cursor */
animation:
typing 2.5s steps(30, end) forwards,
blink 0.75s step-end infinite;
}
@keyframes typing {
from { width: 0 }
to { width: 30ch }
}
@keyframes blink {
0%, 100% { border-color: currentColor }
50% { border-color: transparent }
}The ch unit is the right choice here — it's the width of the 0 character in the current font, which maps cleanly to monospace character width. If you're using Courier New at 24px, 1ch ≈ 14.4px. You count your characters ("Hello, world!" = 13), set width: 13ch, and pass steps(13) to match. That's the whole system.
Worth noting: the border-right trick is the classic cursor implementation. It's a single border that blinks via a separate blink animation running concurrently. The step-end timing on the blink makes it flip instantly — no fading — which looks like a real terminal cursor. If you want a block cursor instead, replace border-right with a box-shadow: inset -0.1em 0 0 currentColor and adjust to taste.
That said, forwards on the typing animation is important. Without it the animation resets to width: 0 after it completes, and your text vanishes. forwards keeps the final frame (full width) in place once the typing is done.
Making It Erase and Loop
A one-shot type animation is fine for a hero headline. But a lot of designs want the "rotating words" effect — type one phrase, pause, erase it, type the next. You can pull this off in pure CSS, though it gets verbose fast.
The trick is chaining steps() in a single animation shorthand with carefully timed delays. One keyframe block handles the type, another handles the erase, and you use animation-delay to sequence multiple phrases. Here's a two-phrase loop:
.typewriter-loop {
font-family: 'Courier New', monospace;
font-size: 1.5rem;
white-space: nowrap;
overflow: hidden;
border-right: 2px solid #a78bfa;
animation:
type1 2s steps(14, end) 0s forwards,
erase1 1s steps(14, end) 2.5s forwards,
type2 2s steps(18, end) 4s forwards,
erase2 1s steps(18, end) 6.5s forwards,
blink 0.7s step-end 0s infinite;
}
@keyframes type1 {
from { width: 0 }
to { width: 14ch }
}
@keyframes erase1 {
from { width: 14ch }
to { width: 0 }
}
@keyframes type2 {
from { width: 0 }
to { width: 18ch }
}
@keyframes erase2 {
from { width: 18ch }
to { width: 0 }
}
@keyframes blink {
50% { border-color: transparent }
}The math: each phrase takes type duration + pause + erase duration. You add them up and set animation-delay on each subsequent pair to start after the previous one finishes. For a true infinite loop with multiple phrases you'd either repeat this pattern (it gets tedious around phrase 4+) or drop to a small JS snippet that cycles data-text values. In practice, pure CSS loops work great for 2-3 phrases; beyond that, JavaScript pays for itself.
Quick aside: if you do switch to JS for the loop, the CSS animation still does all the visual work. Your script just swaps the text content and restarts the animation via element.style.animation = 'none' followed by element.offsetHeight (forces reflow) and then reassigning the animation string. One of those rare cases where a DOM hack is actually the right tool.
The Blinking Cursor in Depth
The border-right cursor works, but it's attached to the text element. That means it stays right after the last typed character, which looks correct while typing. After typing completes though, it just sits there at the end of the line — fine for most uses, but sometimes you want more control.
An alternative: use a ::after pseudo-element as the cursor, completely decoupled from the text. This lets you style it independently, position it with position: absolute, or even animate its height.
.typewriter-wrap {
position: relative;
display: inline-block;
}
.typewriter-wrap::after {
content: '';
position: absolute;
right: -4px; /* 4px gap between text and cursor */
top: 0;
bottom: 0;
width: 2px;
background: #a78bfa;
animation: blink 0.75s step-end infinite;
}
@keyframes blink {
0%, 100% { opacity: 1 }
50% { opacity: 0 }
}Look, opacity vs border-color transparent are both valid blink approaches. opacity is slightly better for compositing — the browser can blink it on its own layer without triggering a repaint on the text. border-color changes trigger a paint. Not a visible difference in 2026 on any real device, but it's the more correct approach if you're being precise.
If you want a fat block cursor like a 1984 terminal, set width: 1ch on the pseudo-element and position it over the last character. Combine with a mix-blend-mode: difference and you get the classic inverted-block cursor look with zero extra markup.
Handling Variable-Length Strings
The single biggest pain with steps() typewriter animations is that you hardcode the character count. Change the text, break the animation. This is especially annoying in component-based frameworks where the text comes from a prop or a CMS.
One approach: CSS custom properties. You expose --chars and --duration as variables and set them inline on the element.
<p
class="typewriter"
style="--chars: 21; --duration: 2.1s"
>
Empire UI is pretty great
</p>
```
```css
.typewriter {
width: 0;
overflow: hidden;
white-space: nowrap;
border-right: 2px solid currentColor;
animation:
typing var(--duration, 2s) steps(var(--chars, 20), end) forwards,
blink 0.75s step-end infinite;
}
@keyframes typing {
to { width: calc(var(--chars, 20) * 1ch) }
}In a React or Vue component, you'd compute chars from text.length and duration as chars * 0.1 (100ms per character feels natural) and pass them as inline styles. The CSS stays static, the values come from your component logic. That's a clean separation.
Worth noting: ch units assume monospace. If you're using a proportional font (sans-serif, serif), ch won't match actual character widths and the animation will overshoot or undershoot. For proportional fonts you need JavaScript to measure the text width — canvas.measureText() or a hidden span's offsetWidth — and then animate to that pixel value instead. Check out Empire UI's component library for ready-made animated text components that already handle this.
Pairing With Empire UI Styles
A typewriter animation in plain black-on-white is fine for a portfolio. But drop it into a cyberpunk or vaporwave design system and it becomes a statement. The glowing cursor, the scan-line monospace font, the neon border-right — it all fits perfectly.
Here's a quick cyberpunk-flavored version using CSS variables you'd pull from the Empire UI cyberpunk theme tokens:
:root {
--neon-green: #39ff14;
--dark-bg: #0a0a0a;
}
.typewriter-cyber {
font-family: 'Share Tech Mono', monospace;
font-size: 2rem;
color: var(--neon-green);
background: var(--dark-bg);
text-shadow:
0 0 8px var(--neon-green),
0 0 20px var(--neon-green);
border-right: 3px solid var(--neon-green);
box-shadow:
0 0 6px var(--neon-green), /* cursor glow */
inset 0 0 6px transparent;
white-space: nowrap;
overflow: hidden;
width: 0;
animation:
typing 3s steps(24, end) forwards,
blink 0.5s step-end infinite;
}The text-shadow stack makes the text glow without adding DOM elements. The cursor glow comes from box-shadow on the border-right edge — it's a subtle trick but it makes the cursor look like it's actually emitting light rather than just being a colored line.
You can also pair a typewriter heading with a glassmorphism components card beneath it — the frosted panel as a "terminal window" with the typing text above. That combination shows up everywhere in portfolio hero sections right now, and for good reason. It reads as both technical and polished simultaneously.
One more thing — if you want ready-made animated text components instead of hand-rolling all of this, browse components on Empire UI. The text animation section has typewriter, scramble, and split-letter variants that already handle variable-length strings, accessibility (prefers-reduced-motion), and framework integration.
Accessibility and the prefers-reduced-motion Guard
Typewriter animations are motion by definition. Some users have vestibular disorders or cognitive sensitivities where repeated on-screen motion causes real physical discomfort. The prefers-reduced-motion media query exists for exactly this reason, and ignoring it for a purely decorative text effect is a bad call.
The right approach: show the full text immediately when reduced motion is preferred, and optionally keep a static (non-blinking) cursor.
@media (prefers-reduced-motion: reduce) {
.typewriter {
width: auto; /* show all text immediately */
animation: none; /* kill typing + blink */
border-right: none; /* hide cursor entirely */
}
}That four-line block covers every user who's opted into reduced motion. The text is still there, still readable, just not animated. Takes about 30 seconds to add. Do it.
Also worth thinking about screen readers: overflow: hidden with width: 0 means the text *content* is in the DOM and accessible to assistive technology from the start — the animation is purely visual. That's actually a nice property of this technique. You don't need aria-live or visibility tricks. The text is always there, just visually clipped. Screen readers will read the full sentence before the animation completes, which is correct behavior.
FAQ
The ch unit maps to monospace character width, so it breaks with variable-width fonts like Inter or Roboto. You'll need to measure the text width in pixels using a hidden span's offsetWidth or canvas.measureText() and animate to that pixel value instead.
Chain type and erase keyframes with matching steps() counts and staggered animation-delay values. For more than 2-3 phrases this gets unwieldy fast — a small JavaScript snippet cycling animation: none and back is cleaner beyond that.
end (the default) jumps at the end of each interval, so frame zero shows zero characters. start jumps at the beginning, showing one character immediately. For typewriters, end is almost always correct — it feels like the character appears as it's "typed".
Yes. Compute text.length and pass it as a CSS custom property via inline style: style={{ '--chars': text.length }}. Your CSS animation references var(--chars) in both steps() and the width keyframe. Add a key={text} prop to force remount when the text changes and restart the animation.