Text Animation Without External Libraries
External libraries like typed.js add 15 KB for an animation that can be built entirely with Alpine.js and 30 lines of JavaScript. The typewriter effect with variable typing speed, realistic deletion and multiple text phrases looping continuously, without jQuery and without an NPM package.
Table of Contents
- 1. Why build typewriter effects without libraries?
- 2. Basic structure: x-data and the state model
- 3. Typing: character by character output with setTimeout
- 4. Deleting: reverse animation with variable speed
- 5. The loop: multiple phrases in sequence
- 6. The cursor: CSS animation with Alpine control
- 7. Realistic timing: pauses and random variance
- 8. Accessibility: aria-live and prefers-reduced-motion
- 9. Comparison: Alpine.js vs. typed.js vs. Vanilla JS
- 10. Summary
- 11. FAQ
1. Why build typewriter effects without libraries?
The typewriter effect is one of the most common animation requirements on modern hero sections and landing pages. Many developers' first instinct is to install typed.js or typewriter-effect from NPM. Yet at its core the effect is simple: an array of strings, a timer that accesses them character by character, and a cursor that blinks. Relying on an external library brings version drift, added bundle size and another entry point for breaking changes.
With Alpine.js, the typewriter effect is a natural application of the reactive state model. The displayed text is a variable, the timer logic runs inside x-init, and the template renders with x-text without a single manual DOM manipulation. The resulting code is readable, maintainable and fully anchored in the markup: no build step, no module system, no external dependency. Especially in projects using Hyva themes, where neither jQuery nor Knockout.js are present, this approach is the direct and clean solution.
The conceptual advantage also lies in debuggability: since every piece of state lives as Alpine data, it can be inspected directly in the browser devtools. The timing, the currently displayed phrase, the typing index and the delete mode are all visible and changeable, unlike opaque library internals.
2. Basic structure: x-data and the state model
The state of the typewriter effect consists of a handful of variables: the array of phrases to display, the index of the current phrase, the number of characters currently shown, a flag indicating whether deletion or typing is in progress, and the text that is currently visible. Alpine manages this state reactively: every change to displayText immediately triggers a DOM update without a manual document.querySelector.
The x-init directive is the starting point: as soon as Alpine initializes the component, the animation begins. This is the cleanest place for side effects in Alpine, since the DOM already exists, the data is initialized, and no further lifecycle hook is needed. The entire animation cycle runs through nested setTimeout calls that mutate Alpine data and thereby trigger the reactive rendering.
<!-- Typewriter component: full state model -->
<div
x-data="{
phrases: [
'Magento stores without compromise.',
'Hyva themes. Fast. Clean.',
'Alpine.js instead of jQuery.',
'Performance is a feature.'
],
phraseIndex: 0,
charIndex: 0,
isDeleting: false,
displayText: '',
cursorVisible: true,
get currentPhrase() {
return this.phrases[this.phraseIndex];
}
}"
x-init="
// Cursor blink interval, independent of typing loop
setInterval(() => { cursorVisible = !cursorVisible; }, 530);
tick();
"
>
<span x-text="displayText"></span><span
x-show="cursorVisible"
class="inline-block w-0.5 h-5 bg-teal-500 ml-0.5 align-middle"
></span>
</div>
3. Typing: character by character output with setTimeout
The core function tick() is called recursively through setTimeout. When not in delete mode, charIndex is incremented and displayText is set to the substring of the current phrase up to that index. Once the full text is reached, a pause is inserted before switching to delete mode. This structure is better than setInterval because the next interval is only scheduled once the current step has completed, so timers never pile up during slow renders.
The typing delay should not be too uniform. A constant interval of 80ms sounds mechanical and unnatural. A small random variation, delay + Math.random() * 50 - 25, makes the effect more realistic. Certain characters like spaces and punctuation can receive a slightly longer pause, since real typists also pause briefly there. These nuances make the difference between a noticeable mechanism and a convincing animation.
// tick(): the core animation loop
tick() {
const phrase = this.currentPhrase;
const typingDelay = 85 + Math.random() * 40 - 20;
const deleteDelay = 45 + Math.random() * 20;
const pauseAfterType = 1800;
const pauseAfterDelete = 400;
if (!this.isDeleting) {
// Typing forward
this.charIndex++;
this.displayText = phrase.substring(0, this.charIndex);
if (this.charIndex === phrase.length) {
// Finished typing, pause then start deleting
setTimeout(() => {
this.isDeleting = true;
this.tick();
}, pauseAfterType);
return;
}
} else {
// Deleting backward
this.charIndex--;
this.displayText = phrase.substring(0, this.charIndex);
if (this.charIndex === 0) {
// Finished deleting, advance to next phrase
this.isDeleting = false;
this.phraseIndex = (this.phraseIndex + 1) % this.phrases.length;
setTimeout(() => this.tick(), pauseAfterDelete);
return;
}
}
setTimeout(() => this.tick(), this.isDeleting ? deleteDelay : typingDelay);
}
4. Deleting: reverse animation with variable speed
Deletion should run faster than typing, so it feels like a hurried user correcting themselves, and this creates a pleasant rhythm. A delete delay of around 45ms compared to an 85ms typing delay produces this feeling. Slight random variation helps here too, to avoid a mechanical impression. The logical implementation mirrors the typing logic: charIndex is decremented and displayText is set to the shorter substring.
A common problem when implementing the delete effect: the final delete step, where charIndex reaches 0, needs careful handling. If the next phrase starts immediately, a jarring jump without a pause results. Instead, a short pause of 300-500ms should follow complete deletion, giving the viewer time to notice the transition before the next text starts appearing. These pauses matter just as much as the animation steps themselves.
5. The loop: multiple phrases in sequence
Cycling through multiple phrases in an endless loop is the heart of the classic typewriter effect. The phrase index is incremented after complete deletion using the modulo operator: phraseIndex = (phraseIndex + 1) % phrases.length. This means the index automatically returns to the first phrase after the last one, without any explicit condition. This pattern is idiomatic JavaScript and works for any number of phrases.
The phrases should fit together thematically and build a progression, either through escalating statements, contrast, or a shared opening. A common UX pattern is a fixed prefix text in the markup with a changing suffix in the typewriter component: "We build " in the HTML, then "Magento stores." / "Hyva themes." / "fast frontends." alternate in the loop. This creates a semantically coherent statement and avoids re-animating the static part on every cycle.
6. The cursor: CSS animation with Alpine control
A blinking cursor element is a sensitive matter from an accessibility standpoint: blinking that is too fast can trigger problems for people with photosensitive epilepsy. The W3C recommendation is blinking below 3 Hz, or animation-play-state: paused under prefers-reduced-motion. The recommended interval of 530ms (about 1.9 Hz) sits safely below this threshold.
With Alpine, the cursor can be implemented in two ways: through a JavaScript driven x-show toggle, or through a pure CSS animation using @keyframes blink. The CSS variant is more performant and runs on the compositor thread, while the Alpine variant allows finer control, for example showing the cursor while typing and hiding it between phrases. A hybrid solution combines both: the CSS animation runs by default, and Alpine pauses and resets it once a transition begins.
<!-- CSS cursor with Alpine pause control -->
<style>
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
.typewriter-cursor {
display: inline-block;
width: 2px;
height: 1.1em;
background: #5eead4;
margin-left: 2px;
vertical-align: middle;
animation: blink 1.06s step-start infinite;
}
.typewriter-cursor.paused {
animation-play-state: paused;
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.typewriter-cursor { animation: none; opacity: 1; }
}
</style>
<div x-data="typewriter()" x-init="start()">
<span x-text="displayText" class="font-bold text-teal-300"></span>
<span
class="typewriter-cursor"
:class="{ paused: isTransitioning }"
></span>
</div>
7. Realistic timing: pauses and random variance
The most important quality difference between a basic and a convincing typewriter effect is timing. People do not type at a constant pace: they hesitate before long words, speed up on familiar sequences, and pause longer after punctuation. These patterns can be approximated with very little code. A lookup table for character specific delays is enough: commas add 100ms, periods add 200ms, spaces add 40ms, and regular letters get the base delay.
In addition to character specific pauses, global variance helps: Math.random() * 60 - 30 added to the base delay makes the timing fluctuate by roughly 30ms in either direction. This produces a natural rhythm without a perceptible pattern. Too much variance, more than 50 percent, makes the effect feel jittery instead. The pause after fully typing a phrase should be noticeably longer than any other delay: 1500-2500ms lets the viewer actually read the text. Reading pauses that are too short turn the effect into a distraction rather than useful information.
// Realistic timing with per-character delays
function typewriter() {
return {
phrases: ['We build fast stores.', 'Hyva. Alpine. Tailwind.', 'Magento without compromise.'],
phraseIndex: 0,
charIndex: 0,
isDeleting: false,
isTransitioning: false,
displayText: '',
charDelay(char) {
const map = { '.': 220, ',': 130, '!': 220, '?': 200, ' ': 60 };
const base = map[char] ?? 80;
return base + Math.random() * 50 - 25;
},
start() {
this.tick();
},
tick() {
const phrase = this.phrases[this.phraseIndex];
if (!this.isDeleting) {
this.charIndex++;
this.displayText = phrase.substring(0, this.charIndex);
if (this.charIndex >= phrase.length) {
this.isTransitioning = true;
setTimeout(() => { this.isDeleting = true; this.isTransitioning = false; this.tick(); }, 2000);
return;
}
setTimeout(() => this.tick(), this.charDelay(phrase[this.charIndex - 1]));
} else {
this.charIndex--;
this.displayText = phrase.substring(0, this.charIndex);
if (this.charIndex === 0) {
this.isDeleting = false;
this.phraseIndex = (this.phraseIndex + 1) % this.phrases.length;
setTimeout(() => this.tick(), 350);
return;
}
setTimeout(() => this.tick(), 40 + Math.random() * 20);
}
}
};
}
8. Accessibility: aria-live and prefers-reduced-motion
Animated text is problematic for screen reader users: with a naive implementation, every character change would trigger a new announcement and make the output unusable. The solution is aria-live="polite" with aria-atomic="true" on the container, combined with an aria-label that holds the full final text of the current phrase. Screen readers then read the complete phrase once the text has stabilized, not every intermediate step.
The prefers-reduced-motion: reduce CSS media feature is a particular challenge for the typewriter effect, since the animation is inherently motion heavy. The correct handling is to disable the typewriter loop entirely under prefers-reduced-motion and show the first phrase immediately and in full instead. In Alpine, this is achieved by checking the media query inside x-init and, when it matches, setting displayText immediately without ever calling tick(). Every user gets the content; only the presentation form varies.
9. Comparison: Alpine.js vs. typed.js vs. Vanilla JS
Which approach suits which project? The three common implementation paths have different trade offs in bundle size, flexibility and integration. Alpine is already present in many Hyva projects, so the marginal extra code is minimal. typed.js ships an extensive API that means overhead for simple use cases. Pure vanilla JS is the leanest option but also the least reactive.
| Criterion | Alpine.js | typed.js | Vanilla JS |
|---|---|---|---|
| Bundle size (gzip) | ~0 KB extra (already loaded) | ~5 KB extra | ~1 KB extra |
| Reactive data binding | Yes, native | No | No |
| HTML integration | Directly in markup | Requires JS init | Requires JS init |
| External dependency | None (Alpine already present) | typed.js NPM package | None |
| Accessibility (aria) | Directly configurable | Limited | Fully configurable |
For Hyva Magento projects, Alpine.js is the clear winner: the library is already loaded, the component lives in the template without a separate JS file, and reactivity makes state management trivial. Only when highly advanced typewriter features are needed, such as HTML markup within phrases or complex callback systems, does typed.js become a sensible addition, but that does not apply to most hero sections.
Mironsoft
Alpine.js components, Hyva themes and Magento frontend development
Need Alpine.js components for your Magento store?
We build clean Alpine.js components for Hyva themes, from animations and interactive filters to complex checkout flows. No jQuery, no overhead, directly in the markup.
UI Components
Typewriter, slider, modal, tabs: built as Alpine components and integrated directly into Hyva templates
Performance Review
Analyzing existing JS dependencies and replacing them with lean Alpine implementations
Hyva Integration
Anchoring Alpine components correctly in Hyva layouts, with CSP compliant inline scripts
10. Summary
The typewriter effect with Alpine.js is a textbook example of how Alpine enables reactive UI interactions without external dependencies. The core component consists of a state object with a phrase array, a character index and a delete flag, a recursive tick() function using setTimeout, and a template that renders with x-text. Realistic timing through character specific delays and random variance elevates the effect from mechanical to convincing.
Accessibility here is not an optional extra: aria-live="polite" ensures screen readers announce complete phrases, not every intermediate state. Under prefers-reduced-motion, the loop is disabled and the text is shown immediately. These two measures make the typewriter effect usable for every user, not only those who can and want to see the animation.
Typewriter Effect with Alpine.js: Key Takeaways
Core Mechanism
Recursive tick() function using setTimeout that increases or decreases charIndex and sets displayText to the substring. Alpine renders reactively.
Realistic Timing
Character specific delays (punctuation longer), random variance of about 25ms, an 1800ms pause after typing, a 350ms pause after deleting.
Cursor
CSS @keyframes blink for performance, Alpine controls animation-play-state. Static without blinking under prefers-reduced-motion.
Accessibility
aria-live="polite" with aria-atomic="true" for screen readers. Immediate full display of the first phrase under prefers-reduced-motion.