Skip to content
Menu

Experiment #007

Text Reveal

Every word is wrapped in an inline-block span that starts translated down and transparent. An x-intersect.once handler flips a single boolean the first time the block enters the viewport, and a per-word transition-delay staggers the reveal by 60ms. Pure CSS transitions on transform and opacity — no timers, no rAF loop, one IntersectionObserver managed by Alpine.

Scene

Technical details

Experiment
FX-007
Category
Text
Stack
Tailwind + Alpine.js
Difficulty
Intermediate
Dependencies
none
Weight
HTML 0.9 KB · ALPINE 1.9 KB
Added
2026-08-26
Updated
2026-08-26

Lab notes

x-intersect ships with the Alpine bundle inside Livewire. In a standalone Alpine setup, register @alpinejs/intersect first. Scale the stagger with sentence length: 60ms reads well up to ~10 words, drop toward 40ms beyond that so the tail of the sentence does not lag.

Take the code

HTML — code.html
<!-- Text Reveal — Tailwind CSS + Alpine.js -->
<!-- Requires the @alpinejs/intersect plugin. Livewire's bundled Alpine ships it; -->
<!-- in a standalone Alpine setup: Alpine.plugin(intersect) from '@alpinejs/intersect'. -->
<div
    x-data="{
        words: 'Every great interface deserves a proper entrance.'.split(' '),
        shown: false,
    }"
    x-intersect.once="shown = true"
>
    <p class="max-w-md text-2xl font-semibold tracking-tight text-zinc-900">
        <template x-for="(word, index) in words" :key="index">
            <span
                x-text="word"
                :class="shown ? 'translate-y-0 opacity-100' : 'translate-y-[0.6em] opacity-0'"
                :style="shown ? `transition-delay: ${index * 60}ms` : ''"
                class="mr-[0.3em] inline-block transition-[transform,opacity] duration-500 ease-out will-change-transform last:mr-0 motion-reduce:transition-none"
            ></span>
        </template>
    </p>
</div>
Alpine — alpine.js
// Text Reveal — reusable Alpine.js component
//
// Requires the @alpinejs/intersect plugin. Livewire's bundled Alpine ships it.
// In a standalone Alpine setup, register it before Alpine.start():
//
//     import intersect from '@alpinejs/intersect'
//     Alpine.plugin(intersect)

document.addEventListener('alpine:init', () => {
    Alpine.data('textReveal', (text = '', stagger = 60) => ({
        words: text.split(' '),
        shown: false,
        instant: false,

        reveal() {
            this.shown = true
        },

        // Re-runs the sequence: snap every word back without a transition,
        // wait two frames so the hidden state is painted, then reveal again.
        replay() {
            this.instant = true
            this.shown = false

            requestAnimationFrame(() => {
                requestAnimationFrame(() => {
                    this.instant = false
                    this.shown = true
                })
            })
        },

        wordStyle(index) {
            if (this.instant) {
                return 'transition: none'
            }

            return this.shown ? `transition-delay: ${index * stagger}ms` : ''
        },
    }))
})

/*
Usage:

<div x-data="textReveal('Every great interface deserves a proper entrance.')">
    <p x-intersect.once="reveal()" class="max-w-md text-2xl font-semibold text-zinc-900">
        <template x-for="(word, index) in words" :key="index">
            <span
                x-text="word"
                :class="shown ? 'translate-y-0 opacity-100' : 'translate-y-[0.6em] opacity-0'"
                :style="wordStyle(index)"
                class="mr-[0.3em] inline-block transition-[transform,opacity] duration-500 ease-out will-change-transform last:mr-0 motion-reduce:transition-none"
            ></span>
        </template>
    </p>

    <button type="button" @click="replay">Replay</button>
</div>
*/

Adjacent

Specimens nearby

All Text →
FX-008 Text

Copy. Paste. Ship. Zero dependencies. Just Tailwind and Alpine.