CSS Typewriter Effect: Pure CSS vs JS — When Each Is the Right Call
Pure CSS typewriter vs JavaScript — learn exactly when each approach wins, how to build both, and which one belongs in your React component.
The Two Paths — and Why the Choice Actually Matters
There's a surprisingly common mistake in this space: developers reach for a full JS library the moment they want typed text on a hero section, when a dozen lines of pure CSS would've done the job in 2018. And the reverse is just as bad — someone tries to animate three rotating phrases with @keyframes steps() and spends an afternoon fighting timing math.
Here's the thing: both approaches are good. They're just good at different things. Pure CSS handles a single static phrase elegantly and adds zero kilobytes to your bundle. JS (or a React hook) earns its keep the moment you need dynamic strings, multiple phrases cycling, or any kind of user interaction hooked into the animation state.
In practice, about 80% of hero-section typewriter effects could be pure CSS. People just don't realize it because the JS solutions show up first in Google and look easier to drop in. This article will walk you through both — how to build them, where they break down, and how to make the call without second-guessing yourself.
Worth noting: the choice also affects accessibility. A CSS animation running on a ::after pseudo-element can confuse screen readers differently than a JS solution that updates aria-live text. We'll cover that too.
Pure CSS Typewriter: How It Actually Works
The pure CSS trick relies on two things working together: width animating via steps() (not ease), and a blinking cursor faked with border-right. The steps() timing function is what makes it look mechanical — instead of smoothly interpolating between values, it jumps in discrete increments that match your character count.
Here's the minimal version:
``css
.typewriter {
overflow: hidden;
white-space: nowrap;
border-right: 3px solid currentColor;
width: 0;
animation:
typing 2.8s steps(30, end) forwards,
blink 0.75s step-end infinite;
}
@keyframes typing {
to { width: 100%; }
}
@keyframes blink {
50% { border-color: transparent; }
}
`
And the HTML is just <p class="typewriter">Your text here</p>`. That's it.
The steps(30, end) value needs to match your character count. If your text is 30 characters, you write steps(30). Get it wrong by even a few and the animation either overshoots or stops mid-word. This is the CSS version's biggest fragility — the animation is hard-coded to a specific string length. Change the copy and you change the step count.
Honestly, for a marketing hero where the copy is locked in and you're targeting a single phrase, this is genuinely the right tool. No runtime, no hydration cost, no dependency. It works in every browser that shipped after 2016. If you're building UI that values visual polish without JS overhead — like the glassmorphism components that need to feel light — pure CSS is a natural fit.
One more thing — monospace fonts make this look significantly better. Proportional fonts cause the text to visually "jump" because each character has a different width but the animation distributes space evenly. Set font-family: monospace and the per-step jumps all look identical. It's a subtle difference but you notice it immediately side-by-side.
The Multi-Phrase Problem — Where CSS Starts to Crack
What if you want it to type "Build faster.", pause, delete, then type "Ship with confidence."? Now you're in trouble with pure CSS. You can technically chain @keyframes for two phrases, but you need to calculate animation-delay and duration precisely for each phrase length, and you end up with something like 60 lines of CSS that you have to manually update every time the copy changes.
Here's what that chained approach looks like at its simplest:
``css
/* Phrase 1: 14 chars, Phrase 2: 20 chars */
.typewriter {
animation:
type1 1.4s steps(14) forwards,
delete1 0.7s steps(14) 2.4s forwards,
type2 2s steps(20) 3.5s forwards;
}
@keyframes type1 { from { width: 0 } to { width: 14ch } }
@keyframes delete1 { from { width: 14ch } to { width: 0 } }
@keyframes type2 { from { width: 0 } to { width: 20ch } }
``
This works, but it's already fragile and that's only two phrases.
Three or more phrases and you're maintaining a spreadsheet of animation timings in your head. Any copy change cascades into a rewrite. In practice, nobody ships this in production — they switch to JS. And that's exactly the right call.
Quick aside: CSS @property (the Houdini API, available in Chromium since 2021) lets you animate custom properties with steps, which unlocks some creative multi-phase patterns without JS. But it doesn't help with dynamic content and browser support is still inconsistent for this specific use case as of mid-2026. Don't build on it if Firefox users matter to you.
JavaScript Typewriter: The React Hook Approach
For anything dynamic, a custom React hook is the cleanest solution. You keep full control, zero third-party dependency, and the logic stays readable. Here's a useTypewriter hook that handles multiple phrases with configurable speeds:
``tsx
import { useState, useEffect, useRef } from 'react';
interface UseTypewriterOptions {
phrases: string[];
typeSpeed?: number; // ms per character, default 80
deleteSpeed?: number; // ms per character, default 40
pauseDuration?: number; // ms before deleting, default 1500
}
export function useTypewriter({
phrases,
typeSpeed = 80,
deleteSpeed = 40,
pauseDuration = 1500,
}: UseTypewriterOptions) {
const [displayed, setDisplayed] = useState('');
const [phraseIndex, setPhraseIndex] = useState(0);
const [isDeleting, setIsDeleting] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
const current = phrases[phraseIndex % phrases.length];
const tick = () => {
if (!isDeleting) {
setDisplayed(current.slice(0, displayed.length + 1));
if (displayed.length + 1 === current.length) {
timeoutRef.current = setTimeout(() => setIsDeleting(true), pauseDuration);
return;
}
} else {
setDisplayed(current.slice(0, displayed.length - 1));
if (displayed.length - 1 === 0) {
setIsDeleting(false);
setPhraseIndex(i => i + 1);
}
}
};
timeoutRef.current = setTimeout(tick, isDeleting ? deleteSpeed : typeSpeed);
return () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); };
}, [displayed, isDeleting, phraseIndex, phrases, typeSpeed, deleteSpeed, pauseDuration]);
return displayed;
}
``
Using it in a component is straightforward:
``tsx
export function HeroTypewriter() {
const text = useTypewriter({
phrases: ['Build faster.', 'Ship with confidence.', 'Look great doing it.'],
typeSpeed: 70,
deleteSpeed: 35,
});
return (
<h1 aria-live="polite" aria-atomic="true">
{text}
<span aria-hidden="true" className="cursor">|</span>
</h1>
);
}
`
The aria-live="polite" + aria-atomic` combination tells screen readers to announce the full updated text when the typing settles, rather than reading every individual character update. That's the pattern Google recommends as of their 2025 a11y guidelines.
That 80ms default type speed is worth explaining. Research on perceived naturalness for typewriter UIs (there's a small body of it from terminal emulator design, circa 2019–2021) suggests that 60–90ms per character reads as human-like. Under 40ms feels like a machine. Over 120ms feels broken. You can add ±15ms of random jitter to make it feel even more organic:
``ts
const jitter = Math.random() * 30 - 15;
timeoutRef.current = setTimeout(tick, (isDeleting ? deleteSpeed : typeSpeed) + jitter);
``
Look, the hook approach does have one cost: on first render in React 18 Strict Mode, you'll see the animation restart because effects fire twice in development. That's not a production bug — it's Strict Mode working as intended. Don't patch it with a useRef guard that skips the first run; just accept that dev and prod look slightly different here.
If you're building this inside a design system with lots of animated components, it's worth thinking about how your typewriter interacts with other motion on the page. The gradient generator is a good example of layering animations without one drowning the other — sometimes less motion in the background makes the typewriter pop more.
Third-Party Libraries: When They're Worth It
There are good ones. typed.js has been around since 2014 and it's solid — 6kb gzipped, well-maintained, React wrapper available. react-type-animation is the modern choice if you're already in a React app and want declarative configuration. Both handle edge cases you'd spend time debugging in a custom hook: interrupted animations, prefers-reduced-motion, loop counts, smart backspace that only deletes to the divergence point between phrases.
That last feature is underrated. If you're cycling between "React developer" and "React designer", a smart library deletes only the last word rather than the whole phrase. The hook above doesn't do that — it deletes everything. Implementing smart-backspace yourself adds another 30 lines of logic and some careful index math.
That said, for most hero sections? The custom hook is fine. Libraries earn their weight when you have a non-trivial animation sequence, need prefers-reduced-motion handled automatically, or you're shipping a component that five different teams will use and you don't want to re-explain the edge cases every time.
One more thing — if you're using Framer Motion for other animations in your project, you can also build the typewriter effect with motion variants and staggerChildren, animating individual <span> characters. It's heavier and harder to make accessible, but the integration with your existing motion system can be worth it. Check the framer-motion-advanced article for patterns you can adapt.
Accessibility, Performance, and the `prefers-reduced-motion` Rule
This is the part people skip and then get a bug report about it six months later. Constantly updating DOM text triggers reflow. Not massive reflow — it's a text node change inside a fixed-width container — but it does happen on every character. If you're running the typewriter in a component that has other complex paint operations nearby, you'll want to check DevTools paint flashing.
The bigger accessibility issue is prefers-reduced-motion. Users who've enabled this OS setting are typically doing it because animations cause them discomfort. A typewriter effect that runs forever is exactly the kind of animation this setting is designed to stop. The fix is simple:
``css
@media (prefers-reduced-motion: reduce) {
.typewriter {
animation: none;
width: auto;
border-right: none;
}
}
`
And in your React hook:
`ts
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (prefersReduced) return phrases[0]; // just show the first phrase, no animation
``
For screen readers, the pure CSS version is trickier. The text is in the DOM already, so the reader will announce it — but the overflow: hidden combined with animated width means the reader might announce it before it's "typed". Wrap it in aria-hidden="true" and provide a visually-hidden static version for readers:
``html
<span class="typewriter" aria-hidden="true">Build faster.</span>
<span class="sr-only">Build faster. Ship with confidence. Look great doing it.</span>
``
Yes, it's more markup. Yes, it's worth it.
Performance-wise, add will-change: width to the CSS version to hint the browser toward compositor-thread animation. It won't always help — width animation doesn't actually run on the compositor — but it signals intent and browsers sometimes optimize it. In Chrome DevTools Timeline (2025 version), check for green bars on the Frames track; if you see red, something else on the page is the culprit, not the typewriter itself.
Making the Call: A Quick Decision Tree
Here's how to decide without overthinking it. Single static phrase that never changes? Use pure CSS — it's 12 lines, no dependencies, no bundle cost. Multiple phrases, or the strings come from a CMS or API? Write the React hook above; you'll have it running in 20 minutes. Complex animation sequence with backspace-to-divergence, loop counts, or restart triggers? Reach for react-type-animation — the API is ergonomic and the edge cases are handled.
One signal people miss: if your designer wants the cursor to blink at a specific interval — say, exactly 700ms per blink — CSS is easier to control down to the millisecond. JS setInterval timing drifts. Not enough to see on a single blink, but over 20 cycles it accumulates. Use animation: blink 0.7s step-end infinite and it stays perfectly consistent.
The performance argument for pure CSS isn't as strong as people claim, by the way. A setInterval/setTimeout at 80ms intervals is not going to stress a modern device. The real advantage is conceptual simplicity and zero hydration dependency — if your page renders server-side and the JS hasn't parsed yet, the CSS animation already runs. The JS version shows a blank string until React hydrates. For above-the-fold hero sections, that's actually a meaningful UX difference in 2026 when Core Web Vitals still matter for rankings.
You can browse how Empire UI handles layered animations in the neobrutalism and cyberpunk style hubs — both use text effects heavily and the approach there is instructive for how to make motion feel intentional rather than decorative. Typewriters, like all animation, work best when they're telling you something rather than just moving.
FAQ
Technically yes, but you'll hardcode timing math for each phrase length and update it every time copy changes. Two phrases is manageable; three or more is painful to maintain — switch to JS at that point.
Yes. The steps() timing function and border-right cursor trick have worked in Firefox since version 36. No workarounds needed for modern Firefox.
Add a @media (prefers-reduced-motion: reduce) block that sets animation: none and width: auto. In your JS hook, check window.matchMedia('(prefers-reduced-motion: reduce)').matches and skip the animation entirely.
60–90ms per character reads as natural. Under 40ms feels robotic; over 120ms feels broken. Add ±15ms of random jitter if you want it to feel human-typed.