Text Reveal
Smooth animated text reveal with GSAP
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-slotpnpm add gsap @gsap/react @radix-ui/react-slotyarn add gsap @gsap/react @radix-ui/react-slotbun add gsap @gsap/react @radix-ui/react-slotCopy and paste the following code into your project.
"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.
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):
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.
// 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.
// 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.
// 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.
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.
<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.
<TextReveal startVisible>
This text is visible on mount.
</TextReveal>With ScrollTrigger
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(SplitText, ScrollTrigger);Pass scrollTrigger inside the play() timeline vars:
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.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | Text content. Supports inline elements like <strong>, <em>, <span>. |
asChild | boolean | false | When true, renders the child element directly instead of wrapping in a <div>. |
startVisible | boolean | false | When true, text is visible immediately. |
lines | ReactNode[] | [] | Custom line reveals. Cycles if fewer than text lines, slices extras. |
lineClassName | string | — | Classes applied to each SplitText line (e.g. mx-auto for centering). |
textAnimation | gsap.TweenVars | { clipPath, duration: 0.6, stagger: 0.2, ease: "power2.inOut" } | Overrides for the text clip-path animation. |
revealAnimation | gsap.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. |
className | string | — | CSS classes for the container. Text styling classes inherit to children. |
ref | Ref<TextRevealHandle> | — | Imperative handle with play(), reset(), and element. |
TextRevealHandle
Imperative handle exposed via ref.
| Method | Signature | Description |
|---|---|---|
play | (timelineVars?: gsap.TimelineVars) => gsap.core.Timeline | Resets state and plays the animation. Pass scrollTrigger, onStart, onComplete, etc. |
reset | () => void | Resets animation to initial hidden state. |
element | HTMLElement | null | The container DOM element, for ScrollTrigger triggers or DOM queries. |
TextRevealLine
A customizable reveal overlay element, used in the lines array.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | Visual 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!