Skip to content
Menu

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.

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

Specimens nearby

All Text →
FX-007 Text

Every great interface deserves a proper entrance.