A loud yellow and black hero where the final word of the headline swaps every two seconds. The old word rolls up and out, the new one rolls in from below one letter after another, and the black block behind it springs to the new width and tips to a slightly different angle each time. Five bars under the copy show a timer for the current word and work as buttons to pick one. On phones the block wraps onto its own line.
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useInView, useReducedMotion } from "framer-motion";
// The words that rotate. Keep them to about 8 letters so the block fits a phone.
const words = ["electric", "honest", "fearless", "handmade", "alive"];
const INTERVAL_MS = 2000;
const TILTS = [-2, 1.5, -1, 2, -1.5]; // the block leans a little differently for each word
const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
export default function RotatingWordHeadlineWithSpringFlip() {
const reduce = useReducedMotion();
const ref = useRef<HTMLElement>(null);
const inView = useInView(ref, { amount: 0.3 });
const [index, setIndex] = useState(0);
const [widths, setWidths] = useState<number[]>([]);
const measures = useRef<(HTMLSpanElement | null)[]>([]);
// Measure every word once (and on resize) so the block can spring to the right width.
useIsoLayoutEffect(() => {
const measure = () => setWidths(measures.current.map((el) => (el ? el.offsetWidth : 0)));
measure();
window.addEventListener("resize", measure);
return () => window.removeEventListener("resize", measure);
}, []);
useEffect(() => {
if (reduce || !inView) return;
const timer = window.setTimeout(() => setIndex((i) => (i + 1) % words.length), INTERVAL_MS);
return () => window.clearTimeout(timer);
}, [reduce, inView, index]);
// On the server and with reduced motion the first word is simply there.
const still = !!reduce || typeof window === "undefined";
const word = words[index];
const width = widths[index];
const letter = "inline-block";
return (
<section ref={ref} className="relative overflow-hidden bg-yellow-300 px-6 py-20 text-black sm:py-28">
<style>{`
@keyframes rotating-word-headline-with-spring-flip-fill { from { transform: scaleX(0) } to { transform: scaleX(1) } }
@media (prefers-reduced-motion: reduce) { .rotating-word-headline-with-spring-flip-fill { animation: none !important; } }
`}</style>
<div className="mx-auto max-w-6xl">
<div className="flex items-center justify-between border-b-2 border-black pb-3 text-xs font-bold uppercase tracking-widest">
<span>Loud Harbour Films</span>
<span>Reel 2026</span>
</div>
<h2 className="mt-10 text-5xl font-black uppercase leading-none tracking-tighter sm:mt-14 sm:text-7xl lg:text-8xl">
<span className="sr-only">Brand films that feel {words.join(", ")}.</span>
<span aria-hidden="true" className="block">Brand films</span>
<span aria-hidden="true" className="mt-2 flex flex-wrap items-center gap-x-5 gap-y-3 sm:mt-3">
<span>that feel</span>
<motion.span
className="relative inline-block h-[1.15em] overflow-hidden bg-black text-yellow-300"
style={{ perspective: 600 }}
initial={false}
animate={{ width: width || "auto", rotate: reduce ? TILTS[0] : TILTS[index % TILTS.length] }}
transition={{ type: "spring", stiffness: 260, damping: 22 }}
>
{/* Invisible copies used only for measuring */}
{words.map((w, i) => (
<span key={w} ref={(el) => { measures.current[i] = el; }} className="invisible absolute left-0 top-0 whitespace-nowrap px-[0.18em]">
{w}
</span>
))}
{/* Holds the width before the first measurement */}
{!width && <span className="invisible whitespace-nowrap px-[0.18em]">{word}</span>}
<AnimatePresence initial={!still}>
<motion.span key={word} className="absolute inset-y-0 left-0 flex items-center whitespace-nowrap px-[0.18em]">
{[...word].map((ch, i) => (
<motion.span
key={i}
className={letter}
style={{ transformOrigin: "50% 60%" }}
initial={reduce ? false : { y: "95%", rotateX: -80, opacity: 0 }}
animate={{ y: "0%", rotateX: 0, opacity: 1 }}
exit={reduce ? { opacity: 0 } : { y: "-95%", rotateX: 80, opacity: 0 }}
transition={{ type: "spring", stiffness: 380, damping: 26, delay: i * 0.028 }}
>
{ch}
</motion.span>
))}
</motion.span>
</AnimatePresence>
</motion.span>
</span>
</h2>
<div className="mt-12 grid gap-8 sm:mt-16 sm:grid-cols-2 sm:items-end">
<div>
<p className="max-w-md text-lg font-medium leading-relaxed text-black/80">
A six person studio on the docks. We write, shoot and cut launch films for brands that would rather be remembered than liked.
</p>
<div className="mt-7 flex flex-wrap gap-3">
<a href="#" className="inline-flex h-12 items-center bg-black px-6 text-sm font-bold uppercase tracking-wide text-yellow-300 transition hover:bg-neutral-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-black focus-visible:ring-offset-2 focus-visible:ring-offset-yellow-300">
Watch the reel
</a>
<a href="#" className="inline-flex h-12 items-center border-2 border-black px-6 text-sm font-bold uppercase tracking-wide text-black transition hover:bg-black hover:text-yellow-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-black focus-visible:ring-offset-2 focus-visible:ring-offset-yellow-300">
Start a project
</a>
</div>
</div>
<div className="flex gap-2 sm:justify-end" role="group" aria-label="Choose the word">
{words.map((w, i) => (
<button
key={w}
type="button"
aria-pressed={i === index}
aria-label={`Show the word ${w}`}
onClick={() => setIndex(i)}
className="group flex h-10 w-12 items-center focus:outline-none focus-visible:ring-2 focus-visible:ring-black focus-visible:ring-offset-2 focus-visible:ring-offset-yellow-300"
>
<span className="relative block h-1.5 w-full overflow-hidden bg-black/20 transition group-hover:bg-black/40">
{i === index && (
<span
key={index}
className="rotating-word-headline-with-spring-flip-fill absolute inset-0 origin-left bg-black"
style={reduce ? undefined : { animation: `rotating-word-headline-with-spring-flip-fill ${INTERVAL_MS}ms linear both`, animationPlayState: inView ? "running" : "paused" }}
/>
)}
</span>
</button>
))}
</div>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/rotating-word-headline-with-spring-flip.json
Every word is rendered once, invisibly, inside the block. A layout effect reads their widths and stores them, and the block animates its `width` to the stored value for the current word with a spring. The measurement runs again on window resize, so it stays correct when the font size changes at a breakpoint.
`AnimatePresence` keeps the outgoing word mounted while the incoming one enters. Each letter animates `y`, `rotateX` and opacity: entering letters start below and tilted back, leaving letters move up and tilt forward. The block has `perspective` set so the rotation reads as depth, and `overflow-hidden` clips letters at its edges.
The small delay between letters turns a simple swap into a wave, which is what makes the effect feel physical. It costs a few more elements but only transform and opacity are animated.
Yes. The timer is a single timeout that restarts whenever the active word changes, so a manual choice always gets its full two seconds before the rotation continues.
No. The timeout is only scheduled while at least 30 percent of the section is visible, and the timer bar animation is paused with `animation-play-state` at the same time.
Related: Typewriter Headline with Cycling Phrases, Text Scramble Decode Headline, Shimmering Chrome Gradient Headline.
More text effects or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.