Creating a CSS Typewriter Typing Effect Using animation-timing-function: steps()
Build a CSS-only typewriter effect with animation-timing-function: steps(). Understand the layout cost, the monospace requirement, and the reduced-motion fallback.
Why The Timing Function Decides Everything
Most tutorials will tell you a CSS typing effect depends on animating the width of a container from 0 to the full text extent, and they are right. But they leave out the part that decides whether it works: the timing function. The CSS typing effect steps mechanism uses animation-timing-function: steps() to divide the animation duration into equal discrete intervals. Instead of smoothly interpolating the width, which would reveal the text as a blurry, sliding mess, each step jumps the width forward by exactly one glyph. The text appears to type itself one character at a time. This is the core of a typewriter effect without JavaScript.
The catch is that this technique is not free. Animating width triggers layout on every frame, making it a main-thread animation, not a compositor-safe one. You need a monospace font and a known glyph count to calculate the ch unit width. You need to handle reduced-motion preferences. Before you write any code, understand that this is a deliberate trade-off between a familiar, nostalgic effect and performance cost. Here is how to build it, what it costs, and when to walk away.
How The Typewriter Illusion Works
A CSS typewriter animation is a text reveal effect where characters appear one by one, mimicking a mechanical typewriter. The trick is not in the keyframes themselves, those only animate width from 0 to a target value, but in the animation-timing-function. The default is ease, which would interpolate the width smoothly, showing a growing box with partial characters. With steps(), you force the animation to move in discrete jumps, each one exposing a new character and no partial states. For example, steps(20, end) tells the browser to make 20 equal jumps, each lasting 0.1 seconds if the duration is 2 seconds, and each jump holds its value until the next. This is the CSS typing effect steps process: the width goes from 0 to 20ch in 20 steps, so after the first step, the width is 1ch, revealing the first character, and so on. Without steps(), the effect fails; with it, you get the authentic typewriter feel. The step-position value end (same as jump-end) is the default and the one you want, because it makes the last frame hold the full width, avoiding a premature flash of the complete text.
The Layout Cost You Actually Pay
Now that you have a working example, understand what it costs. Animating width is a layout-triggering property. Every frame of the animation, the browser must recalculate the layout of the element and its siblings, because changing the width potentially shifts the position of everything that follows it in the document flow. This is called a layout trigger, and it is not compositor-safe. The compositor thread can only animate properties that do not require layout or paint, such as transform and opacity. When you animate width, the main thread must do the work for every step. If the animation runs for 3 seconds with 13 steps, that is 13 layout recalculations. If the animation runs smoothly at 60fps with steps, the browser still forces a synchronous layout per frame. In practice, this means the typing effect has a high paint cost and a high layout cost, especially if the text is long or the page is complex. The keyframe value changes from 0 to 13ch are not compositor-eligible, so the animation runs entirely on the main thread.
For a single element, this is usually fine. If you have ten typing effects running simultaneously, you will see jank. Reserve this technique for short phrases on simple pages. If you need a performant alternative, consider animating transform: translateX() on a clipped container instead, though that requires a different structure. The width-based approach is the simplest, and for a single line, it is often acceptable.
The Reduced-Motion Fallback Is Not Optional
Every typing effect must respect the user's prefers-reduced-motion setting. The effect is inherently motion-heavy and can trigger vestibular disorders. Provide a static fallback that shows the full text immediately, without animation. Wrap your animation in a @media query that targets the prefers-reduced-motion media feature, and set the animated element to width: auto or remove the animation entirely. Here is the pattern:
@media (prefers-reduced-motion: reduce) {
.typing {
animation: none;
width: 100%;
white-space: normal;
overflow: visible;
border-right: none;
}
}
In this reduced-motion block, the text is fully visible, the caret is removed, and the layout is not constrained. This is a requirement for accessibility. The prefers-reduced-motion media query is supported in all modern browsers, and it is the right place to shut off the effect. This does not remove the layout cost for users who do not opt out, but it ensures the effect is not harmful. Always test with the operating system's reduced-motion setting enabled to confirm the fallback works. This practical detail separates a professional implementation from a copy-paste job.
What This Replaces
Before CSS could do this with steps(), developers used JavaScript with setInterval or requestAnimationFrame to add characters one at a time. An even older technique was an animated GIF of typing text, which was heavy, inflexible, and looked dated. Flash-based text animation was another predecessor, now completely obsolete. The CSS version replaces those with a declarative approach: no JavaScript, no timer management, no manual string slicing.
However, the JavaScript method with Intersection Observer can avoid the layout cost of the CSS version. The JS approach can measure the text width, apply transform translations, and use the compositor thread, whereas the CSS width animation cannot. So the honest comparison is: CSS is simpler and has no script, but it carries a main-thread layout cost. The JS approach is more robust and performant, but it adds dependencies. For a single line of text, the CSS version is usually fine. For anything more complex, measure first. Know the trade-off before choosing.
CSS Text Reveal Animation: Beyond the Basic Width Trick
Single-Line Limits And The max-width Alternative
The width-based technique is the canonical CSS text reveal animation, but it has limitations. It only works on a single line; multi-line typing effects require JavaScript, because the ch unit measures width, not height, and animating height would reveal wrapped lines incorrectly. For single lines, you have the max-width alternative, which allows the text to be dynamic in length without knowing the exact character count. Instead of width: 0 to width: 20ch, you can use max-width: 20ch to max-width: 100%, which still uses steps() but allows the container to size naturally. The catch is that max-width animation can be jumpy if the step count does not match the visual width. Another variation is to animate background-size or clip-path, but those have their own support issues.
The width approach remains the most direct because it uses the ch unit, which is exactly one character width in a monospace font. The ch unit is the reason the character count must be known; without it, the steps would not align. Always use a monospace font, such as Courier New or Consolas, to ensure consistent glyph widths. If you must use a variable-width font, you cannot rely on ch; you would need to measure the text with JavaScript, which defeats the purpose of the pure CSS solution.
animation-timing-function steps: The Core of the Effect
How steps() Divides The Animation
The animation-timing-function property is what makes the typewriter effect tick. The steps() function is a timing function that defines a step function graph, causing the animation to jump between values rather than moving smoothly. The syntax is steps(<integer>[, <step-position>]), where the integer is the number of steps, and the position is either jump-start, jump-end, jump-none, or jump-both. For a typing effect, you want jump-end (the default), which means the first step happens at the beginning of the animation, and the final value holds until the animation ends. jump-start would reveal the first character immediately and end with the second-to-last, cutting off the final character. jump-none and jump-both are rarely needed here.
The step count must equal the number of characters in the string. For a 20-character string, use steps(20, end). If you use a different number, the animation will either reveal all characters before the end (if too few steps) or not reveal all of them (if too many). This is the most common mistake, and it is easy to fix by counting characters carefully. The steps() function is supported everywhere, so no prefix is needed, but the same cannot be said for older edge cases.
Building The Blinking Caret
Use the step-end timing function for the caret. The caret blinks by toggling border-color between transparent and visible. Using steps(1, end) or step-end makes the change instantaneous, rather than a smooth fade. A separate @keyframes blink with 50% { border-color: transparent; } and a duration of 0.75s with step-end ensures the caret is either on or off. This is a discrete animation, not an interpolated one, which is exactly what you want for a blinking cursor. The animation-iteration-count is infinite for the caret, so it keeps blinking. For the typing animation, you want forwards to keep the final width. The animation-fill-mode property controls this; without it, the width resets to 0 after the animation completes.
Remember that the animated element must be inline-block or block, because inline elements ignore width. The overflow: hidden clips the text that hasn't been revealed yet, and white-space: nowrap prevents line breaks. These three properties are non-negotiable for the effect to work.
Common Mistakes and Failure Modes in CSS Typewriter Effects
The most common mistake is omitting white-space: nowrap. Without it, the text wraps, and the width animation reveals a column of text that grows downward, not a line that types across. The second most common mistake is using a step count that does not match the number of characters. This causes the animation to finish before all characters are revealed, or to keep revealing after the text is fully shown, creating an awkward pause. For a 20-character string, use steps(20, end). A third mistake is forgetting overflow: hidden, which leaves the full text visible while the width animates, defeating the entire purpose. A fourth is using a variable-width font, which makes the ch unit unreliable; each character has a different width, so the steps do not align with the glyphs. If you must use a non-monospace font, you cannot use this technique without JavaScript. A fifth mistake is not using the prefers-reduced-motion fallback, which excludes a significant portion of users. Animating width on an element that is not a block or inline-block will fail silently; the width property is ignored. All of these are easy to debug if you know what to look for. The last one is the border-right caret: if you do not set border-right on the container, there is no caret, and the effect loses its typewriter feel.
prefers-reduced-motion Typewriter Fallback: A Practical Guide
The prefers-reduced-motion media query is the correct place to disable the typing effect. The effect is decorative, and for users who opt out, the animation can cause nausea or distraction. The fallback is simple: set animation: none, and let the text display normally. But there is a nuance: if you use width: 0 and overflow: hidden, the text will be invisible without the animation. You must also reset width to auto or 100%, and remove the overflow: hidden and white-space: nowrap to allow the text to wrap naturally. Here is a complete reduced-motion block:
@media (prefers-reduced-motion: reduce) {
.typing {
animation: none;
width: auto;
overflow: visible;
white-space: normal;
border-right: none;
}
}
This ensures the full text is visible and readable. The animation-fill-mode is irrelevant because the animation is removed. This is a best practice, not an afterthought. Browser support for prefers-reduced-motion is excellent; check caniuse for current coverage. The real gap is that some users have the setting enabled at the operating system level, and your CSS must respect it. This is a non-negotiable requirement for any production page, and it is the kind of detail that separates a thoughtful implementation from a lazy one.
FAQ: CSS Typing Effect Steps, Answered for the Working Developer
Why does my typing animation reveal the text all at once?
This happens when you omit steps() from the animation-timing-function. The default is ease, which interpolates the width smoothly. Add steps(N, end) where N is the character count.
Can I use this with a variable-width font?
No, not reliably. The ch unit is based on the width of the zero glyph, and in a proportional font, each glyph has a different width, so the steps will not align with the characters. You would need JavaScript to measure and set a fixed pixel width, which defeats the purpose.
How do I make the caret blink?
Use a separate @keyframes blink that changes border-color to transparent at 50%, and apply it with animation: blink 0.75s step-end infinite. The step-end makes the transition instantaneous.
What if I do not know the exact number of characters?
For dynamic text, use max-width instead of width. Set max-width: 0 to max-width: 100%, and use steps() with a large number that exceeds the character count. The text will reveal with a slight overshoot, but it works without knowing the count.
Is animating width bad for performance?
Yes, animating width triggers layout on every frame, which is a main-thread operation. For a single short line, it is fine, but for multiple animations or long text, consider alternatives like transform or JavaScript with Intersection Observer.
Does this work in all browsers?
steps() has been supported for years, and you need no vendor prefixes. The ch unit is also universal. The only caveat is the prefers-reduced-motion media query; check caniuse for current support details, as older iOS Safari versions may ignore it.
Why does the text disappear after the animation ends?
You need animation-fill-mode: forwards. Without it, the browser reverts the width to 0 after the animation completes. Add forwards to the animation shorthand or set animation-fill-mode: forwards.
When to Avoid This Technique and What to Do Instead
The CSS typewriter effect is not for every situation. If you have more than one line of text, or if the text is long enough to cause significant layout shift, the pure CSS approach fails. For multi-line typing, you need JavaScript to manipulate the text node directly, or you need to use a container with a fixed height and overflow: hidden, but then you cannot use steps() because the height animation does not map to character lines. A better alternative for long text is to use the Intersection Observer API with JavaScript to add characters one by one, measuring the text width and animating transform instead of width. This keeps the animation on the compositor thread and avoids layout cost. However, this adds complexity and a script dependency.
The CSS version is best for a single short phrase like a hero section tagline. It is also a good choice when you want zero JavaScript dependencies. The key is to measure the performance impact. Use the browser's performance profiler to see if the animation causes jank. If it does, switch to the JS approach. What works in a simple CodePen may not work on a complex page. This technique is a single-line, short-text, motion-permitting effect. Respect its limitations, and it will serve you well. If you need more, accept JavaScript as the price of admission.