Every great interface deserves a proper entrance.
Experiment #008
Typewriter Text
A chained Alpine.js timer appends one character roughly every 45ms, holds the completed phrase, then deletes it at double speed before moving to the next one. The cursor is a plain span blinking with a CSS step-end keyframe — no JavaScript involved in the blink. When prefers-reduced-motion is set, the init hook short-circuits and renders the first phrase statically.
Copy. Paste. Ship. Zero dependencies. Just Tailwind and Alpine.
Scene
Technical details
- Experiment
- FX-008
- Category
- Text
- Stack
- Tailwind + Alpine.js + CSS
- Difficulty
- Beginner
- Dependencies
- none
- Weight
- HTML 1.9 KB · ALPINE 2.0 KB · CSS 0.5 KB
- Added
- 2026-08-27
- Updated
- 2026-08-27
Lab notes
Keep typing around 40–60ms per character and deletion about twice as fast — slower typing reads as lag, faster deletion reads as intent. Fix the container width to the longest phrase so surrounding layout never reflows, and keep phrases to one line each.
Take the code
HTML — code.html
<!-- Typewriter Text — Tailwind + Alpine.js -->
<div
x-data="{
phrases: ['Copy. Paste. Ship.', 'Zero dependencies.', 'Just Tailwind and Alpine.'],
text: '',
phrase: 0,
deleting: false,
timer: null,
init() {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
this.text = this.phrases[0]
return
}
this.timer = setTimeout(() => this.tick(), 500)
},
destroy() {
clearTimeout(this.timer)
},
tick() {
const target = this.phrases[this.phrase]
let delay = 45
if (this.deleting) {
this.text = target.slice(0, this.text.length - 1)
delay = 22
if (this.text === '') {
this.deleting = false
this.phrase = (this.phrase + 1) % this.phrases.length
delay = 400
}
} else {
this.text = target.slice(0, this.text.length + 1)
if (this.text === target) {
this.deleting = true
delay = 1600
}
}
this.timer = setTimeout(() => this.tick(), delay)
},
}"
class="w-72 max-w-full"
>
<!-- Static copy for screen readers; the animated line below is decorative. -->
<p class="sr-only">Copy. Paste. Ship. Zero dependencies. Just Tailwind and Alpine.</p>
<p class="font-mono text-base" aria-hidden="true"><span x-text="text"></span><span
class="ml-[3px] inline-block h-[1.1em] w-0.5 translate-y-[0.18em] animate-[typewriter-blink_1s_step-end_infinite] bg-current motion-reduce:animate-none"
></span></p>
</div>
<!-- Cursor blink keyframes: add once to your global CSS. -->
<style>
@keyframes typewriter-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
</style>
Alpine — alpine.js
// Typewriter Text — reusable Alpine.js component
//
// Register once:
// import typewriter from './typewriter'
// Alpine.data('typewriter', typewriter)
//
// Use anywhere:
// <p x-data="typewriter(['First phrase.', 'Second phrase.'])">
// <span x-text="text"></span><span class="typewriter-cursor" aria-hidden="true"></span>
// </p>
export default function typewriter(phrases, options = {}) {
const settings = {
typeSpeed: 45, // ms per typed character
deleteSpeed: 22, // ms per deleted character
holdDelay: 1600, // pause on a completed phrase
nextDelay: 400, // pause on the empty line before the next phrase
startDelay: 500, // initial pause before the first character
...options,
}
return {
phrases,
text: '',
phrase: 0,
deleting: false,
timer: null,
init() {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
this.text = this.phrases[0]
return
}
this.timer = setTimeout(() => this.tick(), settings.startDelay)
},
destroy() {
clearTimeout(this.timer)
},
tick() {
const target = this.phrases[this.phrase]
let delay = settings.typeSpeed
if (this.deleting) {
this.text = target.slice(0, this.text.length - 1)
delay = settings.deleteSpeed
if (this.text === '') {
this.deleting = false
this.phrase = (this.phrase + 1) % this.phrases.length
delay = settings.nextDelay
}
} else {
this.text = target.slice(0, this.text.length + 1)
if (this.text === target) {
this.deleting = true
delay = settings.holdDelay
}
}
this.timer = setTimeout(() => this.tick(), delay)
},
}
}
CSS — styles.css
/* Typewriter Text — blinking cursor (vanilla CSS) */
.typewriter-cursor {
display: inline-block;
width: 2px;
height: 1.1em;
margin-left: 3px;
transform: translateY(0.18em);
background-color: currentColor;
animation: typewriter-blink 1s step-end infinite;
}
@keyframes typewriter-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.typewriter-cursor {
animation: none;
}
}
Adjacent