Random Access Components

Text Reveal

Smooth animated text reveal with GSAP

TailwindCSS iconTailwind CSS
Radix UI iconRadix UI
React IconReact
Next.js IconNext.js
Gsap iconGSAP

Demo

import { TextReveal, TextRevealLine } from "./text-reveal";
import type { TextRevealHandle } from "./text-reveal";

const Demo: React.FC = () => {
const ref = useRef<TextRevealHandle>(null);
const [animating, setAnimating] = useState(false);

return (
  <TextReveal
    ref={ref}
    lines={[<TextRevealLine className="bg-accent" key="0" />]}
    lineClassName="mx-auto"
    className="text-4xl font-bold uppercase text-center leading-tight"
  >
    <strong className="text-accent">REDEFINING</strong> WEB, CHASING
    <strong className="text-accent">PERFORMANCE</strong>, BRINGING IT ALL IN ALL WAYS. DEFINING A
    <strong className="text-accent">STANDARD</strong> WITH RUI ON AND OFF THE WEB.
  </TextReveal>

  <button
    onClick={() => ref.current?.play({
      onStart: () => setAnimating(true),
      onComplete: () => setAnimating(false),
    })}
    disabled={animating}
  >
    {animating ? "Animating..." : "Run animation"}
  </button>
);
};

Installation

npm install gsap @gsap/react @radix-ui/react-slot
pnpm add gsap @gsap/react @radix-ui/react-slot
yarn add gsap @gsap/react @radix-ui/react-slot
bun add gsap @gsap/react @radix-ui/react-slot

Copy and paste the following code into your project.

text-reveal.tsx
"use client";
import gsap from "gsap";
import { SplitText } from "gsap/SplitText";
import { Slot } from "@radix-ui/react-slot";
import { cn } from "@/lib/utils";
import * as React from "react";
import { useGSAP } from "@gsap/react";

gsap.registerPlugin(SplitText);

type TextRevealHandle = {
    /** Resets state and plays the reveal animation. Accepts optional gsap.timeline() vars (e.g. scrollTrigger). */
    play: (timelineVars?: gsap.TimelineVars) => gsap.core.Timeline;
    /** Resets the animation to its initial hidden state. */
    reset: () => void;
    /** The container DOM element. */
    element: HTMLElement | null;
};

function createDefaultLineReveal(): HTMLDivElement {
    const div = document.createElement("div");
    div.className = "high-line-reveal absolute bottom-0 left-0 w-full h-full bg-primary will-change-transform origin-[right_center]";
    return div;
}

function splitAndPrepare(
    container: HTMLElement,
    linesContainer: HTMLElement | null,
    startVisible: boolean,
    asChild: boolean,
    lineClassName?: string
) {
    const targets = asChild
        ? [container]
        : gsap.utils.toArray(
            container.querySelectorAll('[data-slot="text-reveal-content"]')
        ) as HTMLElement[];

    const templates = linesContainer
        ? (Array.from(linesContainer.children) as HTMLElement[])
        : [];

    targets.forEach(target => {
        const split = new SplitText(target, { type: "lines", aria: "none" });
        split.lines.forEach((line, i) => {
            line.classList.add("faded-text", "relative", "w-fit");
            if (lineClassName) line.classList.add(...lineClassName.split(" ").filter(Boolean));

            if (templates.length > 0) {
                const reveal = templates[i % templates.length].cloneNode(true) as HTMLElement;
                reveal.classList.add("high-line-reveal");
                Object.assign(reveal.style, {
                    position: "absolute",
                    bottom: "0",
                    left: "0",
                    width: "100%",
                    height: "100%",
                    willChange: "transform",
                    transformOrigin: "right center",
                });
                line.appendChild(reveal);
            } else {
                line.appendChild(createDefaultLineReveal());
            }
        });

        if (!startVisible) {
            gsap.set(split.lines, { clipPath: "inset(0 100% 0px 0px)" });
        }
    });

    gsap.set(container.querySelectorAll(".high-line-reveal"), {
        scaleX: startVisible ? 0 : 1,
    });
}

function resetState(container: HTMLElement) {
    gsap.set(container.querySelectorAll(".faded-text"), { clipPath: "inset(0 100% 0px 0px)" });
    gsap.set(container.querySelectorAll(".high-line-reveal"), { scaleX: 1 });
}

function TextReveal({
    children,
    className,
    asChild = false,
    startVisible = false,
    lines = [],
    lineClassName,
    textAnimation,
    revealAnimation,
    ref,
    ...props
}: Omit<React.ComponentProps<"div">, "ref"> & {
    asChild?: boolean;
    startVisible?: boolean;
    lines?: React.ReactNode[];
    lineClassName?: string;
    textAnimation?: gsap.TweenVars;
    revealAnimation?: gsap.TweenVars & { at?: string };
    ref?: React.Ref<TextRevealHandle>;
}) {
    const containerRef = React.useRef<HTMLElement>(null);
    const linesRef = React.useRef<HTMLDivElement>(null);

    useGSAP(() => {
        if (!containerRef.current) return;
        splitAndPrepare(containerRef.current, linesRef.current, startVisible, asChild, lineClassName);
    });

    React.useImperativeHandle(ref, () => {
        const play = (timelineVars?: gsap.TimelineVars) => {
            if (!containerRef.current) return gsap.timeline();
            resetState(containerRef.current);

            const fadedTexts = containerRef.current.querySelectorAll(".faded-text");
            const reveals = containerRef.current.querySelectorAll(".high-line-reveal");
            const { at, ...revealVars } = revealAnimation ?? {};

            return gsap.timeline(timelineVars)
                .to(fadedTexts, {
                    clipPath: "inset(0px 0% 0px 0px)",
                    duration: 0.6,
                    stagger: 0.2,
                    ease: "power2.inOut",
                    ...textAnimation,
                })
                .to(reveals, {
                    scaleX: 0,
                    duration: 0.6,
                    stagger: 0.2,
                    ease: "power4.inOut",
                    ...revealVars,
                }, at ?? "<20%");
        };

        const reset = () => {
            if (containerRef.current) resetState(containerRef.current);
        };

        return {
            play,
            reset,
            get element() { return containerRef.current; },
        };
    });

    const linesContainer = lines.length > 0 ? (
        <div ref={linesRef} className="hidden" aria-hidden="true">
            {lines}
        </div>
    ) : null;

    if (asChild) {
        return (
            <>
                <Slot
                    ref={containerRef as React.RefObject<HTMLElement>}
                    data-slot="text-reveal"
                    className={cn("relative", className)}
                    {...props}
                >
                    {children}
                </Slot>
                {linesContainer}
            </>
        );
    }

    return (
        <div
            data-slot="text-reveal"
            ref={containerRef as React.RefObject<HTMLDivElement>}
            className={cn("relative", className)}
            {...props}
        >
            <p data-slot="text-reveal-content">
                {children}
            </p>
            {linesContainer}
        </div>
    );
}
TextReveal.displayName = "TextReveal";

function TextRevealLine({
    className,
    ...props
}: React.ComponentProps<"div">) {
    return (
        <div
            data-slot="text-reveal-line"
            className={cn("bg-accent", className)}
            {...props}
        />
    );
}
TextRevealLine.displayName = "TextRevealLine";

export { TextReveal, TextRevealLine, resetState };
export type { TextRevealHandle };

Usage

Basic

Pass any text or ReactNode as children. Inline elements like <strong>, <em>, or <span> are fully supported.

page.tsx
const ref = useRef<TextRevealHandle>(null);

<TextReveal ref={ref}>
    I'm a text that will be revealed.
</TextReveal>

<button onClick={() => ref.current?.play()}>Animate</button>

Triggering the animation

The ref exposes a play() method that resets state and animates. It accepts optional gsap.TimelineVars (e.g. onStart, onComplete, scrollTrigger):

page.tsx
const ref = useRef<TextRevealHandle>(null);

ref.current?.play({
    onStart: () => console.log("started"),
    onComplete: () => console.log("done"),
});

Custom animation timing

Use textAnimation and revealAnimation to override the default GSAP tween vars. Defaults are merged — you only need to specify the properties you want to change.

page.tsx
// Faster text with slower overlay
<TextReveal
    ref={ref}
    textAnimation={{ duration: 0.8, stagger: 0.05, ease: "power2.out" }}
    revealAnimation={{ duration: 0.8, stagger: 0.05 }}
>
    Your text here.
</TextReveal>

// Change the reveal timeline position (default is "<20%")
<TextReveal
    ref={ref}
    revealAnimation={{ at: "<50%" }}
>
    Different reveal offset.
</TextReveal>

Line layout with lineClassName

Each SplitText line gets w-fit by default (shrinks to content width). Use lineClassName to control how lines are positioned — for example, mx-auto centers them, ml-auto right-aligns them.

page.tsx
// Centered lines
<TextReveal lineClassName="mx-auto">
    Centered text reveal.
</TextReveal>

// Right-aligned lines
<TextReveal lineClassName="ml-auto">
    Right-aligned text reveal.
</TextReveal>

Custom line reveals

Pass an array of TextRevealLine elements via lines. Cycles if fewer than text lines, slices extras.

page.tsx
// Single color — applied to every line
<TextReveal lines={[<TextRevealLine className="bg-red-500" key="0" />]}>
    Your text here.
</TextReveal>

// Alternating colors
<TextReveal
    lines={[
        <TextRevealLine className="bg-red-500" key="0" />,
        <TextRevealLine className="bg-blue-500" key="1" />,
    ]}
>
    Lines alternate between red and blue reveals.
</TextReveal>

When no lines are passed, a default bg-accent reveal is used.

With asChild

Use asChild to render any HTML element as the root. The child element receives all TextReveal props and becomes the direct SplitText target — no wrapper <div> or <p> is added.

page.tsx
const ref = useRef<TextRevealHandle>(null);

<TextReveal ref={ref} asChild>
    <h2>I'm an h2 text that will be revealed.</h2>
</TextReveal>

<button onClick={() => ref.current?.play()}>Animate</button>

This works with any element. The imperative ref still exposes play(), reset(), and element as usual — element returns the child's DOM node directly.

page.tsx
<TextReveal ref={ref} lines={[<TextRevealLine className="bg-red-500" key="0" />]} asChild>
    <h1 className="text-6xl font-bold">Hero title</h1>
</TextReveal>

With startVisible

By default, text is hidden until the animation plays. Set startVisible to render text immediately.

page.tsx
<TextReveal startVisible>
    This text is visible on mount.
</TextReveal>

With ScrollTrigger

page.tsx
import { ScrollTrigger } from "gsap/ScrollTrigger";

gsap.registerPlugin(SplitText, ScrollTrigger);

Pass scrollTrigger inside the play() timeline vars:

page.tsx
const ref = useRef<TextRevealHandle>(null);

useGSAP(() => {
    if (!ref.current) return;
    ref.current.play({
        scrollTrigger: {
            trigger: ref.current.element,
            start: "top 70%",
        },
    });
});

return (
    <TextReveal ref={ref} className="text-4xl font-bold">
        This text reveals <strong>on scroll</strong>.
    </TextReveal>
)

API Reference

TextReveal

The root container. Handles SplitText splitting, line reveal injection, and exposes play()/reset() via ref.

PropTypeDefaultDescription
childrenReactNodeText content. Supports inline elements like <strong>, <em>, <span>.
asChildbooleanfalseWhen true, renders the child element directly instead of wrapping in a <div>.
startVisiblebooleanfalseWhen true, text is visible immediately.
linesReactNode[][]Custom line reveals. Cycles if fewer than text lines, slices extras.
lineClassNamestringClasses applied to each SplitText line (e.g. mx-auto for centering).
textAnimationgsap.TweenVars{ clipPath, duration: 0.6, stagger: 0.2, ease: "power2.inOut" }Overrides for the text clip-path animation.
revealAnimationgsap.TweenVars & { at?: string }{ scaleX: 0, duration: 0.6, stagger: 0.2, ease: "power4.inOut", at: "<20%" }Overrides for the reveal overlay animation. at sets the timeline position.
classNamestringCSS classes for the container. Text styling classes inherit to children.
refRef<TextRevealHandle>Imperative handle with play(), reset(), and element.

TextRevealHandle

Imperative handle exposed via ref.

MethodSignatureDescription
play(timelineVars?: gsap.TimelineVars) => gsap.core.TimelineResets state and plays the animation. Pass scrollTrigger, onStart, onComplete, etc.
reset() => voidResets animation to initial hidden state.
elementHTMLElement | nullThe container DOM element, for ScrollTrigger triggers or DOM queries.

TextRevealLine

A customizable reveal overlay element, used in the lines array.

PropTypeDefaultDescription
classNamestringVisual styling (background, gradient, etc). Defaults to bg-accent.

Techbook

I want to take a moment to focus on some concepts about this component.

When working with animations, finding the right combination of timing and easing to make the animation looks smooth and natural is the key. You also need to consider the context where the animation is going to be used.

For example, this is an alternative implementation with a different timing and easing.

<TextReveal
    textAnimation={{ duration: 0.8, stagger: 0.05, ease: "power2.out" }}
    revealAnimation={{ duration: 0.8, stagger: 0.05 }}
>
    ...
</TextReveal>

And If it seems a bit awkward, maybe it has just a wrong timing, like this one:

<TextReveal
    textAnimation={{ duration: 0.3, stagger: 0.05, ease: "sine.inOut" }}
    revealAnimation={{ duration: 1, stagger: 0.2 }}
>
    ...
</TextReveal>

This is something that goes beyond the library to use or the component itself, it's a matter of trial and error. So don't be scared to experiment and find the right timing for your specific use case!

On this page