Scroll-triggered character animation using GSAP SplitText and ScrollTrigger: each line of a Bringhurst quote is split into individual characters that animate in from y: 60, opacity: 0, rotateX: -90 with a 0.03 s stagger as their block enters the viewport (trigger at top 85%). The animation reverses on scroll-up (toggleActions: play reverse play reverse). A side panel lets you swap the easing curve live -- elastic, bounce, back, power4, expo, circ -- and each change re-splits and re-triggers the animation, so you can compare how the same spatial motion feels under different easing personalities.
Scroll down
Typography is the craft
of endowing human language
with a durable visual form
and thus with an independent
existence.
—Robert Bringhurst
Source
"use client";
import { useRef, useState, useEffect } from "react";
import gsap from "gsap";
import { SplitText } from "gsap/SplitText";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import ScrambleLink from "../ScrambleLink";
gsap.registerPlugin(SplitText, ScrollTrigger);
const LINES = [
"Typography is the craft",
"of endowing human language",
"with a durable visual form",
"and thus with an independent",
"existence.",
"—Robert Bringhurst",
];
const EASE_OPTIONS = [
{ label: "Elastic", value: "elastic.out(1, 0.4)" },
{ label: "Bounce", value: "bounce.out" },
{ label: "Back", value: "back.out(2)" },
{ label: "Power4", value: "power4.out" },
{ label: "Expo", value: "expo.out" },
{ label: "Circ", value: "circ.out" },
];
export default function TextReveal() {
const containerRef = useRef<HTMLDivElement>(null);
const [ease, setEase] = useState("elastic.out(1, 0.4)");
const [dark, setDark] = useState(true);
const [key, setKey] = useState(0);
useEffect(() => {
document.documentElement.classList.add("allow-scroll");
return () => document.documentElement.classList.remove("allow-scroll");
}, []);
useEffect(() => {
if (!containerRef.current) return;
const ctx = gsap.context(() => {
const blocks = gsap.utils.toArray<HTMLElement>(".tr-block");
blocks.forEach((block) => {
const split = SplitText.create(block, {
type: "words,chars",
});
gsap.from(split.chars, {
y: 60,
opacity: 0,
rotateX: -90,
stagger: 0.03,
duration: 0.8,
ease,
scrollTrigger: {
trigger: block,
start: "top 85%",
end: "bottom 20%",
toggleActions: "play reverse play reverse",
},
});
});
}, containerRef);
return () => ctx.revert();
}, [key, ease]);
const replay = () => setKey((k) => k + 1);
return (
<div
ref={containerRef}
className="min-h-[300vh] relative"
style={{
background: dark ? "#0a0a0a" : "#f8f7f4",
transition: "background 0.4s ease",
}}
>
{/* Spacer */}
<div className="h-screen flex items-center justify-center">
<p
className="text-[13px] tracking-[0.12em] uppercase"
style={{
color: dark ? "rgba(255,255,255,0.25)" : "rgba(26,26,46,0.25)",
fontVariationSettings: "'wght' 400, 'SRIF' 100",
}}
>
Scroll down
</p>
</div>
{/* Text blocks */}
<div className="flex flex-col items-center gap-[25vh] px-8 pb-[50vh]">
{LINES.map((line, i) => (
<p
key={`${key}-${i}`}
className="tr-block text-center"
style={{
fontFamily: "var(--font-flux), Georgia, serif",
fontVariationSettings: "'wght' 300, 'SRIF' 500",
fontSize: "clamp(28px, 5vw, 64px)",
color: dark ? "#fff" : "#1a1a2e",
perspective: 600,
lineHeight: 1.2,
}}
>
{line}
</p>
))}
</div>
{/* Controls */}
<div
style={{
position: "fixed",
right: 24,
top: "50%",
transform: "translateY(-50%)",
zIndex: 20,
width: 200,
background: dark ? "rgba(20,20,20,0.92)" : "rgba(255,255,255,0.92)",
backdropFilter: "blur(12px)",
borderRadius: 16,
padding: "20px 22px",
boxShadow: dark
? "0 2px 20px rgba(0,0,0,0.3)"
: "0 2px 20px rgba(0,0,0,0.06)",
border: dark
? "1px solid rgba(255,255,255,0.08)"
: "1px solid rgba(0,0,0,0.06)",
transition: "all 0.4s ease",
}}
>
<span
style={{
fontFamily: "var(--font-flux), sans-serif",
fontVariationSettings: "'wght' 500, 'SRIF' 100",
fontSize: 12,
letterSpacing: "0.1em",
textTransform: "uppercase",
color: dark ? "#fff" : "#1a1a2e",
}}
>
Text Reveal
</span>
<div style={{ marginTop: 16, display: "flex", flexDirection: "column", gap: 8 }}>
{EASE_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => {
setEase(opt.value);
setKey((k) => k + 1);
}}
style={{
fontFamily: "var(--font-flux), sans-serif",
fontVariationSettings: "'wght' 300, 'SRIF' 100",
fontSize: 11,
letterSpacing: "0.06em",
textAlign: "left",
padding: "6px 10px",
borderRadius: 8,
border:
ease === opt.value
? dark
? "1px solid rgba(255,255,255,0.3)"
: "1px solid rgba(26,26,46,0.2)"
: dark
? "1px solid rgba(255,255,255,0.08)"
: "1px solid rgba(0,0,0,0.06)",
background:
ease === opt.value
? dark
? "rgba(255,255,255,0.1)"
: "rgba(26,26,46,0.06)"
: "transparent",
color: dark ? "#fff" : "#1a1a2e",
cursor: "pointer",
transition: "all 0.2s ease",
}}
>
{opt.label}
</button>
))}
</div>
<button
onClick={() => setDark((v) => !v)}
style={{
width: "100%",
padding: "8px 0",
marginTop: 16,
borderRadius: 8,
border: dark
? "1px solid rgba(255,255,255,0.2)"
: "1px solid rgba(26,26,46,0.1)",
background: dark
? "rgba(255,255,255,0.1)"
: "rgba(26,26,46,0.04)",
color: dark ? "#fff" : "#1a1a2e",
fontFamily: "var(--font-flux), sans-serif",
fontVariationSettings: "'wght' 500, 'SRIF' 100",
fontSize: 11,
letterSpacing: "0.08em",
textTransform: "uppercase" as const,
cursor: "pointer",
transition: "all 0.3s ease",
}}
>
{dark ? "Dark" : "Light"}
</button>
<button
onClick={replay}
style={{
width: "100%",
padding: "8px 0",
marginTop: 8,
borderRadius: 8,
border: dark
? "1px solid rgba(255,255,255,0.2)"
: "1px solid rgba(26,26,46,0.1)",
background: "transparent",
color: dark ? "rgba(255,255,255,0.4)" : "rgba(26,26,46,0.4)",
fontFamily: "var(--font-flux), sans-serif",
fontVariationSettings: "'wght' 300, 'SRIF' 100",
fontSize: 11,
letterSpacing: "0.08em",
textTransform: "uppercase" as const,
cursor: "pointer",
}}
>
Replay
</button>
</div>
{/* Nav */}
<div className="fixed bottom-6 left-6" style={{ zIndex: 20 }}>
<ScrambleLink
from="TEXT REVEAL"
to="← LAB"
href="/lab"
className="text-[11px] tracking-[0.1em] text-foreground/30 uppercase hover:text-foreground/60 transition-colors"
style={{ fontVariationSettings: "'wght' 400, 'SRIF' 100" }}
/>
</div>
</div>
);
}