Skip to main content
← Back to Motion Recipes
intermediateText

Text Split Reveal

Reveal a heading word-by-word with GSAP stagger while keeping the sentence accessible.

computed signal@for with trackviewChildrenDestroyRefgsap.fromstaggereasing
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

  • words is a computed() signal over the text input, 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 runs afterRender hooks while prerendering, the callback also checks isPlatformBrowser() before calling matchMedia or 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 }}&nbsp;</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

  1. Create the standalone component with OnPush.
  2. Add the static markup: an outer heading with [attr.aria-label] set to the full sentence, and an @for loop over the split words inside it.
  3. Query the animated elements with viewChildren<ElementRef>('word').
  4. Lazy-load GSAP with await import('gsap') inside afterNextRender().
  5. Build the animation: gsap.from(words, { opacity: 0, y: '100%', stagger: 0.06 }).
  6. Add cleanup: kill the tween in destroyRef.onDestroy().
  7. Add reduced motion: fall back to gsap.set() with the resting values when the user prefers reduced motion.
  8. Test keyboard/accessibility: a screen reader should announce the full sentence once, not once per word — verify with aria-hidden on 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-hidden and an aria-label on 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 y values on an element that isn't display: inline-block (or block) — percentages resolve against the element's own box, so it needs an explicit height to move against.