A focused text reveal for hero statements, editorial intros, product claims, and brand pages where copy should move from atmosphere into clarity.

Focus Text turns a stacked message into a controlled reading sequence.
Each line begins softened, blurred, or visually held back. As the animation progresses, the active line sharpens into full clarity, then the next line receives the same treatment. The result is a quiet progression of attention: one thought comes into focus, then the next, then the complete statement resolves.
Use Focus Text when a page needs to slow the eye without slowing the user. It works for hero copy, campaign statements, portfolio intros, product positioning, studio manifestos, launch lines, and brand-led sections where the writing deserves a measured arrival.
The page job is clarity. The animation should help the visitor read the message in the intended order. It should not turn a simple sentence into a vision test with typography.
npx hyperiux add focus-textimport FocusText from "@/components/effects/focus-text";
const page = () => {
return (
<>
<FocusText/>
</>
)
}
export default page
// Built using Hyperiux Vault: https://vault.hyperiux.com
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { CustomEase } from "gsap/CustomEase";
gsap.registerPlugin(ScrollTrigger, CustomEase);
const FOCUS_EASE = CustomEase.create("focusTextEase", "0.16,1,0.3,1");
const DEFAULT_TEXT =
"Built for seamless interactions.
Designed with thoughtful motion.
Made to feel unforgettable.";
const DEFAULT_CHARACTER_STAGGER = 0.05;
const REDUCED_MOTION_DURATION = 0.3;
export default function FocusText({
text = DEFAULT_TEXT,
className = "",
fontSize = 4.5,
characterStagger = DEFAULT_CHARACTER_STAGGER,
revealDuration = 3.2,
startScale = 0.4,
blurAmount = 18,
holdDuration = 1.2,
loop = false,
scrub = false,
scrollStart = "top 80%",
scrollEnd = "bottom 20%",
backgroundColor = "#000000",
textColor = "#ffffff",
showReplayButton = true,
}) {
const sectionRef = useRef(null);
const tweenRef = useRef(null);
const [isPlaying, setIsPlaying] = useState(true);
const lines = useMemo(
() =>
text.split("
").map((line) => {
const words = line.split(" ");
return words.map((word) => Array.from(word));
}),
[text],
);
const totalCharacterCount = useMemo(
() =>
lines.reduce(
(lineTotal, words) =>
lineTotal +
words.reduce((wordTotal, word) => wordTotal + word.length, 0),
0,
),
[lines],
);
const totalRevealDuration = useMemo(
() =>
revealDuration +
DEFAULT_CHARACTER_STAGGER * Math.max(totalCharacterCount - 1, 0),
[revealDuration, totalCharacterCount],
);
const effectiveRevealDuration = useMemo(
() =>
Math.max(
0.12,
totalRevealDuration -
characterStagger * Math.max(totalCharacterCount - 1, 0),
),
[characterStagger, totalCharacterCount, totalRevealDuration],
);
useEffect(() => {
const section = sectionRef.current;
if (!section) return;
const prefersReducedMotion =
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const ctx = gsap.context(() => {
const characters = Array.from(
section.querySelectorAll("[data-focus-char]"),
);
if (!characters.length) return;
// The heading renders at opacity 0 to avoid a flash of unstyled
// characters before GSAP applies the from-state.
gsap.set(section.querySelector("[data-focus-heading]"), { opacity: 1 });
if (prefersReducedMotion) {
// No scale, no blur, no per-character stagger, no loop and never
// tied to scroll position - just a short fade to the final state.
tweenRef.current = gsap.fromTo(
characters,
{ opacity: 0 },
{
opacity: 1,
duration: REDUCED_MOTION_DURATION,
ease: "none",
onStart: () => setIsPlaying(true),
onComplete: () => setIsPlaying(false),
},
);
return;
}
tweenRef.current = gsap.fromTo(
characters,
{
opacity: 0,
scale: startScale,
filter: `blur(${blurAmount}px)`,
},
{
opacity: 1,
scale: 1,
filter: "blur(0px)",
duration: effectiveRevealDuration,
stagger: characterStagger,
ease: FOCUS_EASE,
// Scrubbing hands playback to the scroll position, so looping and
// the hold between cycles no longer apply.
repeat: !scrub && loop ? -1 : 0,
repeatDelay: holdDuration,
onStart: () => setIsPlaying(true),
onRepeat: () => setIsPlaying(true),
onComplete: () => setIsPlaying(false),
scrollTrigger: scrub
? {
trigger: section,
start: scrollStart,
end: scrollEnd,
scrub: true,
}
: undefined,
},
);
}, section);
return () => {
ctx.revert();
tweenRef.current = null;
};
}, [
lines,
characterStagger,
effectiveRevealDuration,
startScale,
blurAmount,
holdDuration,
loop,
scrub,
scrollStart,
scrollEnd,
]);
const handleReplay = () => {
tweenRef.current?.restart(false, false);
};
return (
<section
ref={sectionRef}
className={`relative flex min-h-screen w-full items-center overflow-hidden px-6 ${className}`}
style={{
backgroundColor,
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
backgroundSize: "cover",
}}
>
<h1
data-focus-heading
className="m-0 w-full font-sans font-black uppercase leading-none tracking-tight opacity-0"
style={{
fontSize: `clamp(2rem, ${fontSize}vw, 8rem)`,
color: textColor,
}}
>
{lines.map((words, lineIndex) => (
<span key={`line-${lineIndex}`} className="block">
{words.map((word, wordIndex) => (
<span key={`line-${lineIndex}-word-${wordIndex}`}>
<span className="inline-block whitespace-nowrap">
{word.map((character, charIndex) => (
<span
key={`${lineIndex}-${wordIndex}-${charIndex}`}
data-focus-char
className="inline-block"
style={{
willChange: "transform, filter, opacity",
transformOrigin: "50% 50%",
backfaceVisibility: "hidden",
}}
>
{character}
</span>
))}
</span>
{wordIndex < words.length - 1 ? (
<span aria-hidden="true" className="whitespace-pre">
{" "}
</span>
) : null}
</span>
))}
</span>
))}
</h1>
{showReplayButton && !scrub && (
<button
type="button"
onClick={handleReplay}
className="absolute inset-x-0 bottom-8 mx-auto inline-flex w-fit items-center justify-center rounded-full border border-white/20 bg-white/8 px-5 py-2.5 font-mono text-[11px] uppercase tracking-[0.28em] text-white transition hover:border-white/40 hover:bg-white/14"
>
Replay animation
</button>
)}
</section>
);
}A product landing page can use Focus Text in the hero to introduce a three-line positioning statement. The first line sharpens, the second follows, and the final line resolves the argument before the visitor reaches the CTA.
The outcome is pacing. The message feels intentional without needing a loud transition, a heavy 3D scene, or a full-screen animation that steals attention from the copy.
A creative studio can use the same pattern for a manifesto-style section where each line carries one part of the brand promise: craft, motion, and commercial clarity.
Not for body copy, legal text, documentation, error messages, form labels, pricing details, or instructions.
Not for long paragraphs. If every line needs time to focus, the page is not cinematic. It is slow.
Not for copy that is weak without motion. Focus Text can pace a good statement; it cannot make a vague one meaningful.
Keep the animated text short, reserve the final text area, animate opacity, transform, and blur with restraint, and avoid repeated replay on minor scroll changes. Large blur values across big text can become expensive, especially on low-power devices.
Let the animation finish and rest. A focused message should not keep breathing beside the CTA like it is nervous.
Expose the complete message once to assistive technology. Do not announce line fragments, duplicate layers, or intermediate blur states. On mobile, reduce blur distance, stagger length, and scale movement so the text remains easy to read.
For reduced motion, render the final clear text immediately or use a short fade. Remove blur travel, line sequencing, scale movement, and repeated replay.
| Prop | Type | Default | Description |
|---|---|---|---|
backgroundColor | string | #000000 | Background color. |
textColor | string | #ffffff | Text color. |
fontSize | number | 4.5 | Text size in vw units. |
characterStagger | number | 0.05 | Delay between each character reveal. |
revealDuration | number | 3.2 | Duration of each character reveal. |
startScale | number | 0.4 | Starting scale before characters settle. |
blurAmount | number | 18 | Initial blur amount in pixels. |
holdDuration | number | 1.2 | Pause duration after the full reveal completes. |
loop | boolean | false | Automatically replay the animation on a loop. |
scrub | boolean | false | Links the reveal to scroll progress instead of playing on mount. Disables looping and the replay button. |
scrollStart | string | top 80% | ScrollTrigger start value used when scrub is enabled. |
scrollEnd | string | bottom 20% | ScrollTrigger end value used when scrub is enabled. |
showReplayButton | boolean | true | Shows the built-in replay button inside the component. |
Focus Text is a React text animation that brings lines of copy into clarity one by one using blur, opacity, scale, or focus-style motion.
Use it when a short stacked statement needs a controlled reading sequence: hero copy, campaign intros, product claims, studio positioning, or editorial section openers.
Not exactly. Blur Text usually reveals a phrase or group of words through a soft blur-in. Focus Text is more sequential: it guides attention line by line as each part of the message sharpens.
Keep it short. Two to four lines work best. More than that starts to feel like the user is waiting for the page to read itself.
Render the full message as real text and expose it once. Hide decorative duplicate layers from assistive technology where needed, and avoid making screen readers experience the animation sequence.
Show the final readable text immediately or with a minimal fade. Remove blur travel, staggered focus movement, scaling, and repeated loops.
Show the final readable text immediately or with a minimal fade. Remove blur travel, staggered focus movement, scaling, and repeated loops.
Yes. A custom version should define line structure, blur intensity, timing, typography rules, trigger behavior, accessibility handling, mobile tuning, reduced-motion fallback, source handoff, and implementation notes.
Need a custom effect? Tell us what to create.