A warm, light hero for a product launch. Every letter of the headline starts scattered across the screen, spun and out of focus, and springs into its place one after another. Underneath, a drawn product window begins lying almost flat, then tips up in perspective and settles with a soft overshoot. Once it has landed it sways slowly, leans toward the pointer, and its invoice rows flip from Chasing to Paid while a floating receipt card updates. On phones the sidebar and extra columns drop away and the window keeps its tilt.
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useMotionValue, useReducedMotion, useSpring, useTransform } from "framer-motion";
/** The headline, word by word. `accent` words are set in the serif italic. */
const headline = [
{ text: "Invoices" },
{ text: "that" },
{ text: "chase" },
{ text: "themselves.", accent: true },
];
const invoices = [
{ client: "Northwind Bakery", initials: "NB", tone: "bg-orange-100 text-orange-800", number: "INV-2041", amount: "$4,280.00" },
{ client: "Harbour Street Dental", initials: "HS", tone: "bg-sky-100 text-sky-800", number: "INV-2040", amount: "$1,950.00" },
{ client: "Okafor & Lindqvist", initials: "OL", tone: "bg-emerald-100 text-emerald-800", number: "INV-2039", amount: "$7,400.00" },
{ client: "Paper Kite Studio", initials: "PK", tone: "bg-rose-100 text-rose-800", number: "INV-2038", amount: "$860.00" },
{ client: "Alder Row Cycles", initials: "AR", tone: "bg-violet-100 text-violet-800", number: "INV-2037", amount: "$2,315.00" },
];
const nav = ["Overview", "Invoices", "Clients", "Reminders", "Payouts"];
const bars = [38, 52, 44, 66, 58, 82, 74, 96];
/** A repeatable "random" number between 0 and 1, so the server and browser agree. */
const rnd = (i: number, salt: number) => {
const x = Math.sin(i * 127.1 + salt * 311.7) * 43758.5453;
return x - Math.floor(x);
};
export default function HeroWithFlyingLetterHeadlineAnd3dProductWindow() {
const reduce = !!useReducedMotion();
const ref = useRef<HTMLElement>(null);
const [onScreen, setOnScreen] = useState(true);
const [paid, setPaid] = useState(1);
// Pointer position across the section, from -0.5 to 0.5, smoothed by springs.
const mx = useMotionValue(0);
const my = useMotionValue(0);
const sx = useSpring(mx, { stiffness: 70, damping: 16 });
const sy = useSpring(my, { stiffness: 70, damping: 16 });
const tiltY = useTransform(sx, [-0.5, 0.5], [-9, 9]);
const tiltX = useTransform(sy, [-0.5, 0.5], [5, -5]);
const shadowX = useTransform(sx, [-0.5, 0.5], [40, -40]);
useEffect(() => {
const el = ref.current;
if (!el || typeof IntersectionObserver === "undefined") return;
const io = new IntersectionObserver(([e]) => setOnScreen(e.isIntersecting), { threshold: 0.05 });
io.observe(el);
return () => io.disconnect();
}, []);
// Every 2.2 seconds the next invoice gets paid; after the last one the list starts over.
useEffect(() => {
if (reduce || !onScreen) return;
const id = setInterval(() => {
if (!document.hidden) setPaid((p) => (p >= invoices.length ? 1 : p + 1));
}, 2200);
return () => clearInterval(id);
}, [reduce, onScreen]);
const onMove = (e: React.PointerEvent<HTMLElement>) => {
if (reduce) return;
const r = e.currentTarget.getBoundingClientRect();
mx.set((e.clientX - r.left) / r.width - 0.5);
my.set((e.clientY - r.top) / r.height - 0.5);
};
const onLeave = () => {
mx.set(0);
my.set(0);
};
const ring = "focus:outline-none focus-visible:ring-2 focus-visible:ring-orange-600 focus-visible:ring-offset-2 focus-visible:ring-offset-amber-50";
const latest = invoices[paid - 1];
let letterIndex = 0;
return (
<section
ref={ref}
data-paused={!onScreen}
onPointerMove={onMove}
onPointerLeave={onLeave}
className="hfl-root relative overflow-hidden bg-amber-50 px-5 pt-16 text-stone-900 sm:px-8 sm:pt-24"
>
<style>{`
@keyframes hero-with-flying-letter-headline-and-3d-product-window-sway {
0%, 100% { transform: rotateY(-2.5deg) translateY(0); }
50% { transform: rotateY(2.5deg) translateY(-10px); }
}
@keyframes hero-with-flying-letter-headline-and-3d-product-window-bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
.hfl-sway { animation: hero-with-flying-letter-headline-and-3d-product-window-sway 9s ease-in-out infinite; }
.hfl-bob { animation: hero-with-flying-letter-headline-and-3d-product-window-bob 5s ease-in-out infinite; }
.hfl-root[data-paused="true"] * { animation-play-state: paused !important; }
@media (prefers-reduced-motion: reduce) { .hfl-sway, .hfl-bob { animation: none; } }
`}</style>
{/* Paper backdrop: a faint ruled grid and a warm glow where the window lands. */}
<div
aria-hidden="true"
className="absolute inset-0 opacity-60"
style={{
backgroundImage: "linear-gradient(to right, rgba(120,53,15,0.07) 1px, transparent 1px), linear-gradient(to bottom, rgba(120,53,15,0.07) 1px, transparent 1px)",
backgroundSize: "56px 56px",
maskImage: "radial-gradient(ellipse 70% 60% at 50% 30%, black, transparent)",
WebkitMaskImage: "radial-gradient(ellipse 70% 60% at 50% 30%, black, transparent)",
}}
/>
<div aria-hidden="true" className="absolute left-1/2 top-[55%] h-[520px] w-[900px] max-w-none -translate-x-1/2 rounded-full bg-orange-300/50 blur-3xl" />
<div className="relative mx-auto max-w-6xl text-center">
<motion.p
initial={reduce ? false : { opacity: 0, y: -12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 120, damping: 16 }}
className="inline-flex items-center gap-2 rounded-full border border-stone-900/10 bg-white/70 px-3.5 py-1.5 text-sm font-medium text-stone-700"
>
<span className="h-2 w-2 rounded-full bg-orange-600" />
Quillpay 3 is here
</motion.p>
<h1 aria-label={headline.map((w) => w.text).join(" ")} className="mx-auto mt-6 max-w-4xl text-5xl font-semibold leading-[1.02] tracking-tight sm:text-7xl lg:text-8xl">
{headline.map((word, wi) => (
<span key={wi} aria-hidden="true" className={`inline-block whitespace-nowrap ${word.accent ? "font-serif font-normal italic text-orange-600" : ""}`}>
{word.text.split("").map((ch, ci) => {
const i = letterIndex++;
return (
<motion.span
key={ci}
className="inline-block will-change-transform"
initial={
reduce
? false
: {
opacity: 0,
x: (rnd(i, 1) - 0.5) * 760,
y: (rnd(i, 2) - 0.5) * 520 - 60,
rotate: (rnd(i, 3) - 0.5) * 260,
scale: 0.4 + rnd(i, 4) * 2.4,
filter: "blur(8px)",
}
}
animate={{ opacity: 1, x: 0, y: 0, rotate: 0, scale: 1, filter: "blur(0px)" }}
transition={{
type: "spring",
stiffness: 95,
damping: 13,
mass: 0.9,
delay: 0.05 + i * 0.026,
opacity: { duration: 0.35, delay: 0.05 + i * 0.026 },
filter: { duration: 0.5, delay: 0.05 + i * 0.026 },
}}
>
{ch}
</motion.span>
);
})}
{wi < headline.length - 1 ? " " : ""}
</span>
))}
</h1>
<motion.div initial={reduce ? false : { opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ type: "spring", stiffness: 90, damping: 18, delay: 0.75 }}>
<p className="mx-auto mt-6 max-w-xl text-lg leading-relaxed text-stone-700">
Send the invoice once. Quillpay follows up politely, takes the card payment and matches it in your books while you get on with the work.
</p>
<div className="mt-8 flex flex-col items-stretch justify-center gap-3 sm:flex-row sm:items-center">
<a href="#" className={`inline-flex items-center justify-center rounded-full bg-stone-900 px-7 py-3.5 text-sm font-semibold text-white shadow-lg shadow-stone-900/25 transition hover:bg-orange-600 ${ring}`}>
Send your first invoice
</a>
<a href="#" className={`inline-flex items-center justify-center rounded-full border border-stone-900/20 bg-white/60 px-7 py-3.5 text-sm font-semibold text-stone-900 transition hover:bg-white ${ring}`}>
See a two minute tour
</a>
</div>
</motion.div>
{/* The stage. Perspective lives here; the window tips up from flat on the table and settles. */}
<div aria-hidden="true" className="relative mx-auto mt-14 max-w-5xl sm:mt-16" style={{ perspective: "1600px" }}>
<motion.div
className="absolute -bottom-6 left-[8%] right-[8%] h-24 rounded-full bg-amber-950/40 blur-3xl"
style={{ x: shadowX }}
initial={reduce ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 1.2, delay: 0.6 }}
/>
<motion.div
style={{ transformStyle: "preserve-3d", transformOrigin: "50% 100%" }}
initial={reduce ? false : { opacity: 0, rotateX: 74, rotateZ: -16, y: 220, scale: 0.72 }}
animate={{ opacity: 1, rotateX: 20, rotateZ: 0, y: 0, scale: 1 }}
transition={{ type: "spring", stiffness: 42, damping: 13, mass: 1.1, delay: 0.3, opacity: { duration: 0.5, delay: 0.3 } }}
>
<motion.div style={{ rotateX: tiltX, rotateY: tiltY, transformStyle: "preserve-3d" }}>
<div className="hfl-sway" style={{ transformStyle: "preserve-3d" }}>
<div className="overflow-hidden rounded-t-2xl border border-b-0 border-stone-900/10 bg-white text-left shadow-2xl shadow-amber-900/30">
<div className="flex items-center gap-2 border-b border-stone-200 bg-stone-50 px-4 py-3">
<span className="h-3 w-3 rounded-full bg-rose-400" />
<span className="h-3 w-3 rounded-full bg-amber-400" />
<span className="h-3 w-3 rounded-full bg-emerald-400" />
<span className="ml-3 hidden rounded-md bg-white px-3 py-1 text-xs text-stone-500 ring-1 ring-stone-200 sm:block">Quillpay · Invoices · October</span>
</div>
<div className="grid md:grid-cols-[190px_1fr]">
<div className="hidden border-r border-stone-200 bg-stone-50/70 p-4 md:block">
<div className="flex items-center gap-2">
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-orange-600 font-serif text-sm italic text-white">Q</span>
<span className="text-sm font-semibold">Quillpay</span>
</div>
<ul className="mt-6 space-y-1 text-sm">
{nav.map((n) => (
<li key={n} className={`rounded-lg px-3 py-2 ${n === "Invoices" ? "bg-stone-900 font-medium text-white" : "text-stone-600"}`}>
{n}
</li>
))}
</ul>
<div className="mt-8 rounded-xl border border-stone-200 bg-white p-3">
<p className="text-xs text-stone-500">Collected, 8 weeks</p>
<div className="mt-3 flex h-16 items-end gap-1.5">
{bars.map((h, i) => (
<motion.span
key={i}
className={`flex-1 origin-bottom rounded-sm ${i === bars.length - 1 ? "bg-orange-500" : "bg-stone-300"}`}
style={{ height: `${h}%` }}
initial={reduce ? false : { scaleY: 0 }}
animate={{ scaleY: 1 }}
transition={{ type: "spring", stiffness: 120, damping: 14, delay: 1.1 + i * 0.05 }}
/>
))}
</div>
</div>
</div>
<div className="p-4 sm:p-6">
<div className="flex items-center justify-between gap-4">
<p className="text-lg font-semibold">Invoices</p>
<span className="rounded-full bg-orange-600 px-3.5 py-1.5 text-xs font-semibold text-white">New invoice</span>
</div>
<div className="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-3">
<div className="rounded-xl border border-stone-200 p-3">
<p className="text-xs text-stone-500">Outstanding</p>
<p className="mt-1 text-xl font-semibold tabular-nums">$18,420</p>
</div>
<div className="rounded-xl border border-stone-200 p-3">
<p className="text-xs text-stone-500">Paid this month</p>
<p className="mt-1 text-xl font-semibold tabular-nums">$46,900</p>
</div>
<div className="hidden rounded-xl border border-stone-200 p-3 sm:block">
<p className="text-xs text-stone-500">Average days to pay</p>
<p className="mt-1 text-xl font-semibold tabular-nums">6.2</p>
</div>
</div>
<ul className="mt-4 divide-y divide-stone-100 rounded-xl border border-stone-200">
{invoices.map((inv, i) => {
const isPaid = i < paid;
return (
<li key={inv.number} className="flex items-center gap-3 px-3 py-2.5 text-sm">
<span className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-semibold ${inv.tone}`}>{inv.initials}</span>
<span className="min-w-0 flex-1 truncate font-medium">{inv.client}</span>
<span className="hidden text-stone-500 sm:block">{inv.number}</span>
<span className="hidden w-24 text-right tabular-nums sm:block">{inv.amount}</span>
<span className="relative flex w-20 justify-end">
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={isPaid ? "paid" : "chasing"}
initial={reduce ? false : { opacity: 0, scale: 0.6, y: 6 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.6, y: -6 }}
transition={{ type: "spring", stiffness: 380, damping: 20 }}
className={`rounded-full px-2.5 py-1 text-xs font-semibold ${isPaid ? "bg-emerald-100 text-emerald-800" : "bg-amber-100 text-amber-800"}`}
>
{isPaid ? "Paid" : "Chasing"}
</motion.span>
</AnimatePresence>
</span>
</li>
);
})}
</ul>
</div>
</div>
</div>
{/* Two cards that float above the glass, at their own depth. */}
<motion.div
className="absolute -right-2 top-16 w-56 sm:-right-10 sm:top-24 sm:w-64"
style={{ translateZ: 90 }}
initial={reduce ? false : { opacity: 0, scale: 0.5, y: 40 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
transition={{ type: "spring", stiffness: 160, damping: 13, delay: 1.05 }}
>
<div className="hfl-bob rounded-2xl border border-stone-900/10 bg-white p-3.5 text-left shadow-2xl shadow-amber-900/30">
<div className="flex items-center gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-emerald-500 text-white">
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
<path d="M4.5 10.5l3.5 3.5 7.5-8" />
</svg>
</span>
<div className="min-w-0">
<p className="text-xs text-stone-500">Payment received</p>
<AnimatePresence mode="wait" initial={false}>
<motion.p
key={latest.number}
initial={reduce ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.2 }}
className="truncate text-sm font-semibold tabular-nums"
>
{latest.amount} · {latest.client}
</motion.p>
</AnimatePresence>
</div>
</div>
</div>
</motion.div>
<motion.div
className="absolute -left-12 -top-12 hidden sm:block"
style={{ translateZ: 60 }}
initial={reduce ? false : { opacity: 0, scale: 0.5, y: 40 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
transition={{ type: "spring", stiffness: 160, damping: 13, delay: 1.2 }}
>
<div className="hfl-bob rounded-2xl bg-stone-900 px-4 py-3 text-left text-white shadow-2xl shadow-stone-900/40 [animation-delay:-2.5s]">
<p className="text-xs text-stone-400">Reminder 2 of 3</p>
<p className="mt-0.5 text-sm font-medium">Sent to Alder Row Cycles</p>
</div>
</motion.div>
</div>
</motion.div>
</motion.div>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/hero-with-flying-letter-headline-and-3d-product-window.json
The headline is split into words and each word into letters. Every letter is an inline block with its own starting offset, rotation, scale and blur, and a spring animates all of those back to zero. A small delay per letter makes them arrive in reading order. Words are wrapped in a no-wrap span so a line never breaks in the middle of a word.
They come from a small formula based on the letter's index, so the server-rendered page and the browser calculate the same numbers. Real random values would differ between the two and cause a hydration warning.
The wrapper sets a CSS perspective. Inside it are three nested layers: one plays the entrance from nearly flat to a slight tilt, one applies the pointer lean through spring values, and one runs a slow sway keyframe. Each keeps `transform-style: preserve-3d`, which lets the two floating cards sit at their own depth with `translateZ`.
Yes. Replace the contents of the white window block with an image that has a width and height set. Keep the rounded top corners and the shadow on the wrapper so the perspective still reads.
The lean follows pointer events, so it responds to a finger moving across the hero as well. Without any input the window still sways on its own, so nothing is lost on a phone.
Related: Dark Hero with Gradient Glow and Product Window, Hero with Live Updating Dashboard Mock, 3D Tilt Card with Parallax Layers and Glare.
More hero sections 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.