Dirck Mulder
Text Animations||3 min read

Create a Scroll Reveal Text Effect in React

Reveal text word by word or line by line as the user scrolls, using Framer Motion's viewport detection.

The best content feels like it is being unveiled, not just displayed. Scroll Reveal gives you that by tying the text's appearance directly to the user's reading progress. Scroll down, and the words appear. Stop scrolling, and they pause. It makes content feel responsive to the reader.

The final result

ScrollReveal splits text into words, then scrubs their opacity and blur from faded to fully visible as the section enters the viewport. The whole block also starts slightly rotated and corrects to zero, adding a subtle tilt-to-flat effect.

Setting up

This uses GSAP with ScrollTrigger. Like ScrollFloat, the scrub: true option ties animation to scroll position.

tsx
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';

gsap.registerPlugin(ScrollTrigger);

Building the component

The split happens in a useMemo. We split on whitespace but preserve the whitespace tokens so spacing is maintained in the rendered output.

tsx
const splitText = useMemo(() => {
  const text = typeof children === 'string' ? children : '';
  return text.split(/(\s+)/).map((word, index) => {
    if (word.match(/^\s+$/)) return word;
    return (
      <span className='inline-block word' key={index}>
        {word}
      </span>
    );
  });
}, [children]);

In useEffect we set up three separate GSAP animations on the container element.

The first rotates the whole block from a base angle to zero as you scroll.

tsx
gsap.fromTo(
  el,
  { transformOrigin: '0% 50%', rotate: baseRotation },
  {
    ease: 'none',
    rotate: 0,
    scrollTrigger: { trigger: el, scroller, start: 'top bottom', end: rotationEnd, scrub: true },
  }
);

The second fades individual words from a low base opacity to fully opaque, staggered so they appear left to right.

tsx
gsap.fromTo(
  wordElements,
  { opacity: baseOpacity, willChange: 'opacity' },
  {
    ease: 'none',
    opacity: 1,
    stagger: 0.05,
    scrollTrigger: {
      trigger: el,
      scroller,
      start: 'top bottom-=20%',
      end: wordAnimationEnd,
      scrub: true,
    },
  }
);

The third removes blur from each word, if enableBlur is true.

tsx
if (enableBlur) {
  gsap.fromTo(
    wordElements,
    { filter: `blur(${blurStrength}px)` },
    {
      ease: 'none',
      filter: 'blur(0px)',
      stagger: 0.05,
      scrollTrigger: { trigger: el, scroller, start: 'top bottom-=20%', end: wordAnimationEnd, scrub: true },
    }
  );
}

Running these three animations separately lets you tune them independently. You might want the rotation to finish before the words are fully opaque, or the blur to linger longer than the opacity.

How to use it

tsx
<ScrollReveal
  enableBlur={true}
  baseOpacity={0.1}
  baseRotation={3}
  blurStrength={4}
>
  Building great interfaces takes patience, craft, and a willingness to keep refining.
</ScrollReveal>

Key takeaways

  • Splitting text with a regex that captures whitespace (/(\s+)/) preserves the original spacing without needing to manually reinsert spaces between words.
  • Three separate scrubbed animations on the same trigger creates a layered effect that feels more sophisticated than a single transform.
  • Starting words at opacity: 0.1 rather than 0 keeps them barely visible, which gives the reader a sense of what is coming and makes the reveal feel like it is enhancing something rather than hiding it.