How to Build a Pure CSS Typing Effect That Actually Works (No JavaScript Required)

How to Build a Pure CSS Typing Effect That Actually Works (No JavaScript Required)

You just finished a clean hero section. The layout is tight, the font is right, and you’ve got that one line of text you want to animate — letter by letter, like someone is typing it in real time. So you Google it. Every top result either hands you a 40-line JavaScript snippet, pulls in a third-party library, or both. You just want one sentence to type itself out. You don’t want to debug someone else’s npm package for a text animation.

I’m Rohan Ratnayake, and I’ve spent the last 5 years as a front-end UI engineer specializing in performance-first, CSS-heavy interfaces, and I’ve watched this exact situation eat up hours on projects that had no business being complicated. Most developers assume a typing animation requires JavaScript. It doesn’t. The confusion comes from tutorials written before CSS animations matured — and those tutorials still dominate search results even though they’re teaching a 2015 solution to a 2025 problem.

The hard way I learned this: I once pushed a landing page with a JS-based typing library that added 18KB of overhead for a single animation. It broke on a client’s old Android browser, caused a layout shift that tanked the Lighthouse score by 11 points, and I had to roll it back at 11pm. The fix — pure CSS — took 20 minutes and worked everywhere. That page now has a 99 performance score. What I’ll show you here is exactly that system: clean, browser-native, zero JavaScript.

What Actually Makes the CSS Typing Effect Work

Before writing a single line, you need to understand the two CSS mechanics doing all the heavy lifting. Miss either one, and the effect breaks.

The steps() timing function is the real engine. Normal CSS animations interpolate smoothly between values — that’s how you get a gradual fade or a slide. steps() tells the animation to jump in hard, discrete increments instead of flowing. So instead of slowly widening a text container, the browser snaps to each step. Each step reveals one more character. That’s the “typing” illusion.

The width animation with overflow: hidden is the container trick. You wrap your text in an element, set overflow: hidden, and animate its width from 0 to 100%. The text is always there in the DOM — you’re just masking it. As the width grows, more characters become visible. Pair that with steps() and you get a precise, per-character reveal.

ALSO READ:  How to Add Text Outlines in CSS Without Breaking Your Layout (The text-shadow Method That Actually Works)

The third piece is the cursor. A blinking border-right on the same element creates the classic caret without a single extra DOM node.

Here’s the foundational CSS structure — this is the base everything else builds on:

.typing-text {
  display: inline-block;
  overflow: hidden;
  white-space: nowrap;
  border-right: 2px solid #333;
  width: 0;
  animation:
    typing 3s steps(30, end) forwards,
    blink-caret 0.75s step-end infinite;
}

@keyframes typing {
  from { width: 0 }
  to   { width: 100% }
}

@keyframes blink-caret {
  from, to { border-color: transparent }
  50%      { border-color: #333 }
}

The steps(30, end) value corresponds to the character count of your text. If your sentence is 30 characters, use steps(30). This is the one number you’ll adjust per line. The end keyword means the jump happens at the end of each interval — which is what makes the reveal feel like actual typing rather than a blur.


The One Thing Most Tutorials Get Wrong

Almost every CSS typing tutorial online tells you to set a fixed pixel width on the container. Something like width: 24ch. That works on a desktop, fails on mobile, and completely breaks if you ever change the copy. You’re hardcoding a value that should be dynamic.

The real approach is to set width: 100% as the end state, but constrain the parent container to fit the text width exactly. Here’s the comparison:

ApproachResponsiveCopy-Change ProofBrowser Support
Fixed px widthNoNoYes
Fixed ch unitsPartialPartialYes
width: 100% + inline-block parentYesYesYes
max-content with width: max-contentYesYesModern only

Using display: inline-block on the parent and width: 100% on the animated child is the most universally compatible method. It lets the text determine the container width naturally, so you’re never hunting down a magic number when the copy changes.


Building the Blinking Cursor Properly

The cursor deserves its own section because most implementations get it half right. A border-right animating between transparent and a solid color looks fine — until someone uses a dark background and forgets to update the color, or the cursor disappears during the typing phase because the animation states conflict.

Here’s what a clean, separated cursor setup looks like:

.typing-wrapper {
  display: inline-flex;
  align-items: center;
}

.typing-text {
  overflow: hidden;
  white-space: nowrap;
  width: 0;
  animation: typing 2.5s steps(25, end) forwards;
}

.cursor {
  display: inline-block;
  width: 2px;
  height: 1.1em;
  background-color: currentColor;
  margin-left: 2px;
  animation: blink 0.75s step-end infinite;
}

@keyframes blink {
  0%, 100% { opacity: 1; }
  50%       { opacity: 0; }
}

Breaking the cursor into its own element gives you three immediate benefits:

  • You can style it independently (color, thickness, shape)
  • You can remove it after typing finishes with animation-fill-mode: forwards on the cursor
  • It won’t interfere with the text container’s border or padding
ALSO READ:  CSS Glitch Art: How to Build a Cyberpunk Text Distortion Effect Using Only Pseudo-Elements

To stop the cursor blinking after typing completes, add an animation delay equal to your typing duration and use forwards fill mode:

.cursor {
  animation: blink 0.75s step-end 2.5s forwards;
}

@keyframes blink {
  0%, 50% { opacity: 1; }
  51%, 100% { opacity: 0; }
}

After 2.5 seconds (when typing ends), the cursor fades out and stays gone. Clean finish.


Typing Multiple Lines in Sequence

This is where most single-line examples fall apart. A chatbot UI or hero section often needs 2–3 lines to appear one after another. The trick is chaining animations using animation-delay.

<div class="chat-sequence">
  <p class="line line-1">Hello, I'm your assistant.</p>
  <p class="line line-2">What can I help you with today?</p>
</div>
.line {
  overflow: hidden;
  white-space: nowrap;
  width: 0;
  opacity: 0;
}

.line-1 {
  animation:
    appear 0s 0s forwards,
    typing 2s steps(22, end) 0s forwards;
}

.line-2 {
  animation:
    appear 0s 2.2s forwards,
    typing 2.5s steps(30, end) 2.2s forwards;
}

@keyframes appear {
  to { opacity: 1; }
}

The appear animation handles visibility — without it, the second line flashes into view at zero width, which looks broken. Each line starts hidden (opacity: 0), then instantly becomes visible at its delay point, then types itself out.

Here’s a timing reference for sequencing multiple lines:

LineCharactersTyping DurationDelayEnd Time
Line 1222s0s2s
Line 2302.5s2.2s4.7s
Line 3181.8s5s6.8s

Add 0.2s of buffer between the end of one line and the start of the next. It feels more natural — like a real person pausing between thoughts.


Browser Support and the prefers-reduced-motion Problem

According to MDN Web Docs, CSS animations have had near-universal browser support since 2012, so the core technique works everywhere. The real concern is accessibility.

Some users have vestibular disorders or motion sensitivities. The prefers-reduced-motion media query exists specifically for this. Ignoring it on a typing animation isn’t just bad practice — it can actively cause discomfort. You can check current support coverage for this media query at Can I Use, where it currently sits above 96% global support.

ALSO READ:  Fade-In Text on Scroll: The Clean Intersection Observer Method That Actually Works

Here’s the pattern to always include:

@media (prefers-reduced-motion: reduce) {
  .typing-text {
    animation: none;
    width: 100%;
    opacity: 1;
  }

  .cursor {
    display: none;
  }
}

When reduced motion is on, the text is just… there. Fully visible, no animation. That’s the right call. The content reaches every user, and the ones who need a still page get exactly that.


When This Technique Doesn’t Work (And What to Do Instead)

The CSS typing effect has one genuine limitation: it only works reliably with monospace fonts. Each character in a monospace font is the same width, which is why steps() can reveal one character per step cleanly.

With proportional fonts like Georgia or a sans-serif like Futura, the characters have different widths. A capital “W” is wider than a lowercase “i”. The steps() animation still jumps at even intervals, but those intervals don’t align with character boundaries. The result is that letters appear to reveal mid-character, which looks wrong.

Font TypeWorks With CSS Typing?Example Fonts
MonospaceYesCourier New, Monaco, Source Code Pro, JetBrains Mono
Proportional SerifNoGeorgia, Times New Roman
Proportional SansNoHelvetica, Futura, Lato

If your design calls for a non-monospace font, you have two options: switch the typing section specifically to a monospace font (which actually looks great for code-UI or chatbot aesthetics), or accept that a JS solution is the right tool for that specific situation. Don’t force the CSS technique where it doesn’t fit.


FAQ

Can I loop the typing animation indefinitely? Yes. Change forwards to infinite on the typing keyframe and add alternate to reverse it. Add a animation-direction: alternate property and it’ll type forward, pause, then erase itself and repeat. Be cautious with looping on live pages — it can become distracting and some accessibility guidelines treat persistent motion the same as auto-playing video.

Why does my text jump to full width before the animation starts? You’ve likely set an initial width value somewhere else that’s overriding the from { width: 0 } state. Check for a parent container, a CSS reset, or a utility class applying width: 100% to your element. Also confirm white-space: nowrap is set — without it, the text wraps and breaks the single-line reveal entirely.

How do I calculate the exact number of steps for my text? Count the characters including spaces and punctuation. That number goes into steps(N, end). A 28-character sentence uses steps(28, end). You can also use a quick browser console trick: "your sentence here".length returns the exact count instantly.


Wrapping Up

The core tension here is simple: developers reach for JavaScript because that’s what the tutorials show, not because it’s what the problem requires. A typing animation is a visual effect. CSS handles visual effects. Keeping it in CSS means less to load, less to break, and less to maintain.

From here, take the multi-line sequence pattern and build one real component — a hero headline, a chatbot intro, a code editor UI. Build it in a CodePen or local file first so you can tweak the step counts and delays without touching a live project. Once you feel how steps() maps to character counts, the whole thing clicks and you’ll never reach for a library for this again.