A bouncy text reveal effect powered by GSAP. Split text into characters and animate them in with a spring-like bounce.
<BounceText
text="Get in touch!"
className="text-4xl font-bold"
as="h1"
/>Copy and paste the following code into your project.
"use client";
import { useGSAP } from "@gsap/react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { SplitText } from "gsap/SplitText";
import {
type ComponentPropsWithoutRef,
type ElementType,
type Ref,
useImperativeHandle,
useMemo,
useRef,
} from "react";
gsap.registerPlugin(SplitText, ScrollTrigger);
const defaultFromVars: gsap.TweenVars = {
duration: 0.4,
ease: "back.out(1.4)",
opacity: 0,
stagger: 0.05,
y: 20,
};
interface BounceTextRef {
/** To manually handle the animation within a timeline. Accepts optional gsap.timeline() vars (e.g. scrollTrigger). */
play: (timelineVars?: gsap.TimelineVars) => gsap.core.Timeline;
}
type BounceTextProps<T extends ElementType> = {
as?: T;
text: string;
options?: gsap.TweenVars;
onAnimationUpdate?: (status: "idle" | "playing") => void;
/** Reference to control the animation from a parent. */
ref?: Ref<BounceTextRef>;
} & Omit<ComponentPropsWithoutRef<T>, "as" | "children" | "ref">;
function BounceText<T extends ElementType = "span">({
as,
text,
options,
onAnimationUpdate,
ref,
...props
}: BounceTextProps<T>) {
const containerRef = useRef<HTMLElement>(null);
const splitRef = useRef<SplitText | null>(null);
const optionsRef = useRef(options);
const onAnimationUpdateRef = useRef(onAnimationUpdate);
optionsRef.current = options;
onAnimationUpdateRef.current = onAnimationUpdate;
const shouldReduceMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
const Comp = (as ?? "span") as ElementType;
const { contextSafe } = useGSAP(
() => {
if (!containerRef.current) {
return;
}
const split = new SplitText(containerRef.current, { type: "chars" });
splitRef.current = split;
gsap.from(split.chars, {
...defaultFromVars,
onComplete: () => onAnimationUpdateRef.current?.("idle"),
onStart: () => onAnimationUpdateRef.current?.("playing"),
...optionsRef.current,
...(shouldReduceMotion ? { duration: 0 } : {}),
});
return () => {
split.revert();
splitRef.current = null;
};
},
{ scope: containerRef }
);
const play = useMemo(
() =>
contextSafe((timelineVars?: gsap.TimelineVars) => {
const chars = splitRef.current?.chars ?? [];
const userOnStart = timelineVars?.onStart;
const userOnComplete = timelineVars?.onComplete;
const userOnInterrupt = timelineVars?.onInterrupt;
const mergedVars: gsap.TimelineVars = {
...timelineVars,
onComplete(this: gsap.core.Timeline, ...args: unknown[]) {
onAnimationUpdateRef.current?.("idle");
userOnComplete?.apply(this, args as never);
},
onInterrupt(this: gsap.core.Timeline, ...args: unknown[]) {
onAnimationUpdateRef.current?.("idle");
userOnInterrupt?.apply(this, args as never);
},
onStart(this: gsap.core.Timeline, ...args: unknown[]) {
onAnimationUpdateRef.current?.("playing");
userOnStart?.apply(this, args as never);
},
};
return gsap.timeline(mergedVars).from(chars, {
...defaultFromVars,
...optionsRef.current,
});
}),
[contextSafe]
);
useImperativeHandle(
ref,
() => ({
play: (timelineVars?: gsap.TimelineVars) => play(timelineVars),
}),
[play]
);
return (
<Comp aria-label={text} ref={containerRef} {...props}>
{text}
</Comp>
);
}
export type { BounceTextRef };
export { BounceText };It automatically animates on mount
<BounceText
text="Get in touch!"
className="text-4xl font-bold"
as="p"
/>The ref exposes a play() method. It returns a GSAP timeline and accepts optional gsap.TimelineVars:
const ref = useRef<BounceTextRef>(null);
<BounceText ref={ref} text="Get in touch!" as="p" />
<button onClick={() => ref.current?.play()}>Replay</button>Pass scrollTrigger into play() so the trigger sits on the timeline, not on the character tween:
const ref = useRef<BounceTextRef>(null);
useGSAP(() => {
ref.current?.play({
scrollTrigger: {
trigger: "#idTriggerElement",
start: "top 70%",
end: "bottom center",
toggleActions: "play none none reverse",
},
});
});
<BounceText ref={ref} text="Get in touch!" as="p" />The components exposes a play() method through the ref. You can use it to easily animate the component within a timeline.
const ref = useRef<BounceTextRef>(null);
useGSAP(() => {
const tl = gsap.timeline();
tl.from('.first-element', {
opacity: 1,
duration: 0.5,
ease: "power2.inOut",
}).
add(ref.current?.play(), {
opacity: 0,
duration: 0.5,
ease: "power2.inOut",
},'<50%')
.from('.third-element', {
opacity: 1,
duration: 0.5,
ease: "power2.inOut",
},'<+=0.1');
});
return (
<div>
<h1 className="first-element">First element</h1>
<BounceText ref={ref} text="Get in touch!" as="h2" />
<h3 className="third-element">Third element</h3>
</div>
)It accepts all the props of the HTML element it renders as, plus the following:
| Prop | Type | Description |
|---|---|---|
text | string | The text to display. |
as | React.ElementType | The HTML element to render as. Default is span. |
options | gsap.TweenVars | Merged into the character tween (duration, ease, stagger, from-state). |
onAnimationUpdate | (status: "idle" | "playing") => void | Called when the bounce starts and finishes. |
ref | Ref<BounceTextRef> | Imperative handle for play(). |
| Member | Signature | Description |
|---|---|---|
play | (timelineVars?: gsap.TimelineVars) => gsap.core.Timeline | Creates and returns a GSAP timeline that replays the bounce. Use for buttons, parent timelines, or ScrollTrigger. |