A phrase-level text animation for hero headlines, product claims, campaign lines, and brand statements where the message needs motion without becoming a full carousel.

Depth Flip turns a static headline into a controlled sequence of short statements.
The effect works by transitioning one phrase into another through a layered flip motion. The outgoing line compresses, shifts, or folds through depth while the next phrase resolves into place. It gives the text a spatial handoff without needing a large 3D scene, heavy WebGL layer, or full-screen route transition.
Use Depth Flip when a page needs to rotate between a small set of connected statements: product positioning, campaign claims, launch lines, value propositions, feature labels, or short brand phrases. The page job is compression: say more than one thing in the same visual space without making the visitor read a dense block.
The production risk is over-rotation. A flipping headline can sharpen a hero, but it can also weaken the message if the copy changes too quickly, loops forever, or hides the most important claim. The strongest version uses a small set of phrases that belong to the same argument and lets each one settle long enough to be read.
npx hyperiux add depth-flip-textimport DepthFlipText from "@/components/effects/depth-flip-text";
const page = () => {
return (
<>
<DepthFlipText/>
</>
)
}
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 { useGSAP } from "@gsap/react";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { SplitText } from "gsap/SplitText";
gsap.registerPlugin(useGSAP, SplitText, ScrollTrigger);
const DEFAULT_PHRASES = [
"Hyperiux Vault ships faster",
"Premium UI for modern teams",
"Built to launch beautifully",
];
const LINE_HEIGHT = 0.9;
const CHAR_PERSPECTIVE = 1200;
const FLIP_EASE = "power4.inOut";
const DepthFlipText = ({ phrases = DEFAULT_PHRASES, className = "", backgroundColor = "#f6f5f2", textColor = "#050505", loop = false, holdDuration = 0.4, transitionDuration = 1.4, charStagger = 0.02, useOpacityTransition = false, scrub = false, scrollStart = "top 80%", scrollEnd = "bottom 20%", }) => {
const normalizedPhrases = useMemo(() => phrases.map((phrase) => phrase.trim()).filter(Boolean), [phrases]);
const [activeIndex, setActiveIndex] = useState(0);
const [fontsReady, setFontsReady] = useState(false);
const containerRef = useRef(null);
const currentRef = useRef(null);
const nextRef = useRef(null);
// Splitting against a fallback font would measure the wrong face height and
// reflow once the real font lands, so stay hidden until the font is in.
useEffect(() => {
let cancelled = false;
const ready = document.fonts?.ready ?? Promise.resolve();
ready.then(() => {
if (!cancelled)
setFontsReady(true);
});
return () => {
cancelled = true;
};
}, []);
const displayIndex = scrub ? 0 : activeIndex;
const isLast = displayIndex >= normalizedPhrases.length - 1;
const hasNext = normalizedPhrases.length > 1 && (!isLast || (!scrub && loop));
const nextIndex = isLast ? 0 : displayIndex + 1;
const currentPhrase = normalizedPhrases[displayIndex] ?? "";
const nextPhrase = hasNext ? (normalizedPhrases[nextIndex] ?? "") : "";
useGSAP(() => {
const currentEl = currentRef.current;
const nextEl = nextRef.current;
if (!fontsReady || !currentEl || !nextEl)
return;
const currentSplit = SplitText.create(currentEl, { type: "words, chars" });
const nextSplit = SplitText.create(nextEl, { type: "words, chars" });
const cleanup = () => {
currentSplit.revert();
nextSplit.revert();
};
const prefersReduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
// The crossfade rides on the characters, never on the paragraph — an
// opacity below 1 on the paragraph would flatten its 3D children.
gsap.set([currentEl, nextEl], { opacity: 1 });
if (prefersReduced) {
gsap.set(currentSplit.chars, {
clearProps: "transform,transformOrigin,transformPerspective,backfaceVisibility,opacity",
});
gsap.set(nextSplit.chars, {
clearProps: "transform,transformOrigin,transformPerspective,backfaceVisibility,opacity",
});
gsap.set(nextEl, { opacity: hasNext ? 0 : 1 });
if (!hasNext)
return cleanup;
gsap
.timeline({
delay: scrub ? 0 : holdDuration,
onComplete: scrub ? undefined : () => setActiveIndex(nextIndex),
scrollTrigger: scrub
? {
trigger: containerRef.current,
start: scrollStart,
end: scrollEnd,
scrub: true,
}
: undefined,
})
.to(currentEl, {
opacity: 0,
duration: transitionDuration,
ease: "power2.out",
}, 0)
.to(nextEl, {
opacity: 1,
duration: transitionDuration,
ease: "power2.out",
}, 0);
return cleanup;
}
if (!hasNext || !currentSplit.chars.length) {
gsap.set(nextSplit.chars, { opacity: 0 });
return cleanup;
}
// Rotating about an axis half a line-height BEHIND the glyph is what
// kills the shift: at rotationX 0 the transform resolves to plain
// identity, so the outgoing face starts, and the incoming face ends,
// exactly on their own layout box. Measured per cycle in px because
// GSAP reads the z-origin with a bare parseFloat.
const faceOffset = currentSplit.chars[0].offsetHeight / 2;
const faceProps = {
transformOrigin: `50% 50% ${-faceOffset}px`,
transformPerspective: CHAR_PERSPECTIVE,
backfaceVisibility: "hidden",
force3D: true,
};
gsap.set(currentSplit.chars, { ...faceProps, rotationX: 0, opacity: 1 });
gsap.set(nextSplit.chars, {
...faceProps,
rotationX: -90,
opacity: useOpacityTransition ? 0 : 1,
});
const advance = () => setActiveIndex(nextIndex);
gsap
.timeline({
delay: scrub ? 0 : holdDuration,
onComplete: scrub ? undefined : advance,
scrollTrigger: scrub
? {
trigger: containerRef.current,
start: scrollStart,
end: scrollEnd,
scrub: true,
}
: undefined,
})
.to(currentSplit.chars, {
rotationX: 90,
opacity: useOpacityTransition ? 0 : 1,
duration: transitionDuration,
ease: FLIP_EASE,
stagger: charStagger,
}, 0)
.to(nextSplit.chars, {
rotationX: 0,
opacity: 1,
duration: transitionDuration,
ease: FLIP_EASE,
stagger: charStagger,
}, 0);
return cleanup;
}, {
scope: containerRef,
// Each cycle re-splits fresh paragraphs, so the previous cycle's splits
// and inline styles have to be reverted rather than accumulated.
revertOnUpdate: true,
dependencies: [
activeIndex,
nextIndex,
hasNext,
fontsReady,
currentPhrase,
nextPhrase,
scrub,
scrollStart,
scrollEnd,
holdDuration,
transitionDuration,
charStagger,
useOpacityTransition,
],
});
return (<section className={`relative flex min-h-screen w-full items-center justify-center overflow-hidden px-4 sm:px-6 ${className}`} style={{ backgroundColor }}>
<div aria-hidden="true" className="absolute inset-0 bg-black/35"/>
<div ref={containerRef} className="relative z-10 w-full max-w-[1400px] max-md:min-h-[18vw]" style={{ opacity: fontsReady ? 1 : 0 }}>
<Phrase key={`current-${activeIndex}`} ref={currentRef} text={currentPhrase} tone={textColor}/>
<Phrase key={`next-${nextIndex}`} ref={nextRef} text={nextPhrase} tone={textColor} secondary/>
</div>
</section>);
};
const Phrase = ({ ref, text, tone, secondary = false }) => (<p ref={ref} className={`m-0 w-full whitespace-nowrap max-md:whitespace-normal text-center text-[6vw] max-md:text-[10vw] max-[1025px]:text-[8vw] font-medium text-white! tracking-[-0.08em] ${secondary ? "absolute inset-0 opacity-0" : "relative"}`} style={{ color: tone, lineHeight: LINE_HEIGHT }}>
{text}
</p>);
export default DepthFlipText;
A developer-tool landing page can use Depth Flip in the hero to rotate between three related claims: speed, source ownership, and production readiness. Each phrase gets its moment, then hands off to the next without forcing a bulky paragraph above the CTA.
The outcome is density with control. The page can say more than one thing without making the first screen feel crowded.
A studio homepage can use the same effect for a short brand sequence where each phrase defines one part of the promise: craft, motion, and commercial clarity.
Not for body copy, long sentences, legal text, documentation, form labels, error messages, pricing details, or instructions.
Not for unrelated phrase sets. If every flip introduces a new topic, the effect becomes a slot machine for positioning.
Not for headlines where one strong sentence would do the job better.
Keep the phrase count small, animate transform and opacity, avoid layout reads during every transition, and clean up timers or animation instances when the component unmounts. Reserve the headline container so phrase changes do not shift surrounding content.
Avoid running multiple Depth Flip instances in the same viewport unless the page is deliberately built around kinetic typography.
Expose the active phrase clearly and avoid making assistive technology read decorative duplicate layers. If the phrase sequence communicates essential meaning, provide a stable text equivalent or ensure each phrase remains understandable without relying on motion.
On mobile, reduce depth distance, scale compression, and transition speed if the effect feels cramped. For reduced motion, show a static preferred phrase or switch phrases instantly without perspective movement.
| Prop | Type | Default | Description |
|---|---|---|---|
phrases | string[] | ["Hyperiux Vault ships faster","Premium UI for modern teams","Built to launch beautifully"] | Phrases that cycle through the perspective flip. |
backgroundColor | string | #f6f5f2 | Background color behind the effect. |
textColor | string | #ffffff | Text color for both the current and incoming phrase. |
loop | boolean | false | Restarts the phrase sequence after the last entry. |
holdDuration | number | 0.4 | Delay before each phrase begins its flip transition. |
transitionDuration | number | 1.4 | Duration of the flip animation. |
charStagger | number | 0.02 | Delay between each character beginning its motion. |
useOpacityTransition | boolean | false | Fades outgoing and incoming characters during the flip. |
scrub | boolean | false | Links the first flip transition to scroll progress instead of autoplay. |
scrollStart | string | top 80% | ScrollTrigger start value used when scrub is enabled. |
scrollEnd | string | bottom 20% | ScrollTrigger end value used when scrub is enabled. |
Depth Flip is a React text animation that transitions between short phrases using a compressed perspective or flip-style motion.
Use it when a hero, campaign section, or product intro needs to rotate between a small set of related claims in the same visual space.
Two to four phrases usually work best. More than that can dilute the message and make the headline feel like an endless loop.
No. Keep the phrases short. The effect works best when each phrase can be read instantly before the next one appears.
Expose the meaningful phrase once, hide decorative duplicate layers where needed, and avoid making the animation the only way to understand the message.
Show a static phrase, display the full phrase set as normal text, or switch instantly without perspective movement, scale compression, or repeated flipping.
Yes. It is strongest in hero sections where the page needs a sharp rotating claim near the main CTA. Keep the timing slow enough for the visitor to read each phrase.
Yes. A custom version should define phrase strategy, timing, perspective depth, typography rules, layout reservation, accessibility handling, reduced-motion fallback, source handoff, and implementation notes.
Need a custom effect? Tell us what to create.