Copy. Paste. Ship. Zero dependencies. Just Tailwind and Alpine.
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.
Every great interface deserves a proper entrance.
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
<!-- 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>
// 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