Text Split Reveal
Reveal a heading word-by-word with GSAP stagger while keeping the sentence accessible.
03
Demo
How it works
The heading text is split into words with a computed() signal — text().split(' ') — and each word is rendered by its own @for loop iteration wrapped in an inline-block span. A viewChildren query collects those spans, and a single gsap.from() call animates them all with a small stagger, sliding each word up from below its own baseline.
The split only ever happens once per input string, because it's derived from a signal rather than recomputed inside the template on every change detection cycle.
The Angular way
wordsis acomputed()signal over thetextinput, so splitting stays declarative and re-runs automatically if the input ever changes.@for (word of words(); track $index)renders one span per word;viewChildren<ElementRef>('word')reads them back as a signal once the view exists.- GSAP loads lazily inside
afterNextRender(). Because this project's SSR setup still runsafterRenderhooks while prerendering, the callback also checksisPlatformBrowser()before callingmatchMediaor importing GSAP. DestroyRef.onDestroy()kills the tween so replaying the animation, or navigating away mid-animation, never leaves two tweens fighting over the same spans.
Source code
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
ElementRef,
afterNextRender,
computed,
inject,
input,
viewChildren,
} from '@angular/core';
import type { gsap } from 'gsap';
@Component({
selector: 'app-text-split-reveal-demo',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="w-full max-w-xl text-center">
<h2
class="text-3xl md:text-5xl font-black tracking-tight text-foreground"
[attr.aria-label]="text()"
>
@for (word of words(); track $index) {
<span #word class="inline-block" aria-hidden="true"
>{{ word }} </span
>
}
</h2>
<button
type="button"
class="mt-8 inline-flex items-center gap-2 rounded-lg border border-border bg-card px-4 py-2 text-sm font-medium text-foreground hover:border-primary/50 transition-colors"
(click)="replay()"
>
<span aria-hidden="true">↻</span> Replay
</button>
</div>
`,
})
export default class TextSplitRevealDemo {
text = input('Build motion the Angular way.');
words = computed(() => this.text().split(' '));
private readonly destroyRef = inject(DestroyRef);
private readonly wordRefs = viewChildren<ElementRef<HTMLElement>>('word');
private gsapInstance: typeof import('gsap').default | null = null;
private tween: gsap.core.Tween | null = null;
constructor() {
afterNextRender(async () => {
const { gsap } = await import('gsap');
this.gsapInstance = gsap;
this.play();
});
this.destroyRef.onDestroy(() => this.tween?.kill());
}
private play() {
if (!this.gsapInstance) return;
const targets = this.wordRefs().map((ref) => ref.nativeElement);
if (!targets.length) return;
this.tween?.kill();
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
this.gsapInstance.set(targets, { opacity: 1, y: 0 });
return;
}
this.tween = this.gsapInstance.from(targets, {
opacity: 0,
y: '100%',
duration: 0.7,
ease: 'power4.out',
stagger: 0.06,
});
}
replay() {
this.play();
}
}
Implementation recipe
- Create the standalone component with
OnPush. - Add the static markup: an outer heading with
[attr.aria-label]set to the full sentence, and an@forloop over the split words inside it. - Query the animated elements with
viewChildren<ElementRef>('word'). - Lazy-load GSAP with
await import('gsap')insideafterNextRender(). - Build the animation:
gsap.from(words, { opacity: 0, y: '100%', stagger: 0.06 }). - Add cleanup: kill the tween in
destroyRef.onDestroy(). - Add reduced motion: fall back to
gsap.set()with the resting values when the user prefers reduced motion. - Test keyboard/accessibility: a screen reader should announce the full sentence once, not once per word — verify with
aria-hiddenon every split span.
Accessibility notes
- aria-hidden on the split spans
- aria-label with the full sentence on the container
- prefers-reduced-motion
Performance notes
- split the text once via a computed(), not on every render
- animate transform and opacity only
Common pitfalls
- Splitting text into spans without
aria-hiddenand anaria-labelon the container — screen readers will read the sentence one word (or letter) at a time. - Splitting on every render instead of via a
computed()— recomputing the word array on unrelated change detection runs can tear down and rebuild the DOM nodes GSAP is animating. - Animating with percentage-based
yvalues on an element that isn'tdisplay: inline-block(or block) — percentages resolve against the element's own box, so it needs an explicit height to move against.