A dark split hero with one living object in it. A large gradient orb changes shape continuously, drifts toward the pointer on a spring, stretches in the direction it travels and shifts hue as it crosses the section. In front of it sits a pile of three frosted glass cards: every few seconds the front card drops away and rejoins at the back, so the orb's colour bleeds through fresh glass each time. With no pointer the orb wanders by itself. On phones the orb and cards move under the text at a smaller size.
import { useEffect, useRef, useState } from "react";
import { motion, useAnimationFrame, useMotionTemplate, useMotionValue, useReducedMotion, useSpring, useTransform, useVelocity } from "framer-motion";
/** The three glass cards. `points` is a sparkline drawn on a 120 by 36 grid. */
const cards = [
{ id: "energy", label: "Energy today", value: "412 kWh", note: "18% below last Tuesday", points: "0,26 14,22 28,27 42,16 56,20 70,10 84,14 98,6 120,9", glyph: "M11 2L4 12h5l-1 6 7-10h-5z" },
{ id: "air", label: "Air quality, floor 3", value: "Excellent", note: "CO2 at 512 ppm and falling", points: "0,10 14,14 28,9 42,17 56,15 70,22 84,19 98,26 120,24", glyph: "M3 8h9a2.5 2.5 0 1 0-2.5-2.5M3 12h13a2.5 2.5 0 1 1-2.5 2.5M3 16h6" },
{ id: "solar", label: "Solar roof", value: "6.4 kW", note: "Feeding 2.1 kW back to the grid", points: "0,30 14,24 28,18 42,10 56,6 70,5 84,9 98,16 120,25", glyph: "M10 6a4 4 0 1 0 0 8 4 4 0 0 0 0-8zM10 1v2M10 17v2M1 10h2M17 10h2M3.6 3.6l1.4 1.4M15 15l1.4 1.4M3.6 16.4L5 15M15 5l1.4-1.4" },
];
/** Where a card sits by its place in the pile: 0 is the front. */
const slots = [
{ y: 0, x: 0, scale: 1, opacity: 1 },
{ y: -26, x: 16, scale: 0.93, opacity: 0.8 },
{ y: -52, x: 32, scale: 0.86, opacity: 0.55 },
];
export default function HeroWithPointerReactiveMorphingOrbAndGlassCardStack() {
const reduce = !!useReducedMotion();
const ref = useRef<HTMLElement>(null);
const live = useRef(true);
const lastMove = useRef(0);
const [onScreen, setOnScreen] = useState(true);
const [order, setOrder] = useState(cards.map((c) => c.id));
const [leaving, setLeaving] = useState<string | null>(null);
const front = useRef(order[0]);
front.current = order[0];
// Pointer position over the section from -1 to 1. Three springs of different weight follow it.
const px = useMotionValue(0);
const py = useMotionValue(0);
const sx = useSpring(px, { stiffness: 55, damping: 12, mass: 1 });
const sy = useSpring(py, { stiffness: 55, damping: 12, mass: 1 });
const gx = useSpring(px, { stiffness: 18, damping: 12 });
const gy = useSpring(py, { stiffness: 18, damping: 12 });
const orbX = useTransform(sx, (v) => v * 64);
const orbY = useTransform(sy, (v) => v * 48);
const glowX = useTransform(gx, (v) => v * 120);
const glowY = useTransform(gy, (v) => v * 90);
const shineX = useTransform(sx, (v) => `${32 + v * 22}%`);
const shineY = useTransform(sy, (v) => `${28 + v * 18}%`);
const shine = useMotionTemplate`radial-gradient(circle at ${shineX} ${shineY}, rgba(255,255,255,0.85), rgba(255,255,255,0) 38%)`;
const hue = useTransform(sx, [-1, 1], [-35, 55]);
const tint = useMotionTemplate`hue-rotate(${hue}deg)`;
// The orb stretches in the direction it is travelling, like a drop of liquid.
const vx = useVelocity(sx);
const vy = useVelocity(sy);
const stretchX = useTransform([vx, vy], ([a, b]: number[]) => 1 + Math.min(Math.abs(a) * 0.09, 0.16) - Math.min(Math.abs(b) * 0.05, 0.08));
const stretchY = useTransform([vx, vy], ([a, b]: number[]) => 1 + Math.min(Math.abs(b) * 0.09, 0.16) - Math.min(Math.abs(a) * 0.05, 0.08));
const pileY = useTransform(sx, (v) => v * -9);
const pileX = useTransform(sy, (v) => v * 7);
useEffect(() => {
const el = ref.current;
if (!el || typeof IntersectionObserver === "undefined") return;
const io = new IntersectionObserver(([e]) => {
live.current = e.isIntersecting;
setOnScreen(e.isIntersecting);
}, { threshold: 0.05 });
io.observe(el);
return () => io.disconnect();
}, []);
// With no pointer around, the orb wanders on its own along a slow looping path.
useAnimationFrame((t) => {
if (reduce || !live.current || document.hidden) return;
if (t - lastMove.current < 2200) return;
px.set(Math.sin(t / 1500) * 0.75);
py.set(Math.cos(t / 2100) * 0.6);
});
// Every 2.8 seconds the front card drops away and rejoins the pile at the back.
useEffect(() => {
if (reduce || !onScreen) return;
let swap: ReturnType<typeof setTimeout>;
const id = setInterval(() => {
if (document.hidden) return;
setLeaving(front.current);
swap = setTimeout(() => {
setOrder((o) => [...o.slice(1), o[0]]);
setLeaving(null);
}, 360);
}, 2800);
return () => {
clearInterval(id);
clearTimeout(swap);
};
}, [reduce, onScreen]);
const onMove = (e: React.PointerEvent<HTMLElement>) => {
if (reduce) return;
const r = e.currentTarget.getBoundingClientRect();
lastMove.current = performance.now();
px.set(((e.clientX - r.left) / r.width - 0.5) * 2);
py.set(((e.clientY - r.top) / r.height - 0.5) * 2);
};
const ring = "focus:outline-none focus-visible:ring-2 focus-visible:ring-lime-300 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950";
return (
<section ref={ref} data-paused={!onScreen} onPointerMove={onMove} className="hpo-root relative overflow-hidden bg-zinc-950 px-6 py-16 text-white sm:py-24">
<style>{`
@keyframes hero-with-pointer-reactive-morphing-orb-and-glass-card-stack-morph {
0%, 100% { border-radius: 42% 58% 63% 37% / 45% 40% 60% 55%; }
25% { border-radius: 58% 42% 38% 62% / 55% 62% 38% 45%; }
50% { border-radius: 35% 65% 55% 45% / 62% 38% 62% 38%; }
75% { border-radius: 62% 38% 46% 54% / 38% 55% 45% 62%; }
}
@keyframes hero-with-pointer-reactive-morphing-orb-and-glass-card-stack-spin { to { transform: rotate(360deg); } }
@keyframes hero-with-pointer-reactive-morphing-orb-and-glass-card-stack-drop {
0% { transform: rotate(200deg) translateX(52%) scale(1); }
50% { transform: rotate(380deg) translateX(44%) scale(0.6); }
100% { transform: rotate(560deg) translateX(52%) scale(1); }
}
.hpo-morph { animation: hero-with-pointer-reactive-morphing-orb-and-glass-card-stack-morph 11s ease-in-out infinite; }
.hpo-spin { animation: hero-with-pointer-reactive-morphing-orb-and-glass-card-stack-spin 18s linear infinite; }
.hpo-drop { animation: hero-with-pointer-reactive-morphing-orb-and-glass-card-stack-drop 14s linear infinite; transform: rotate(200deg) translateX(52%); }
.hpo-root[data-paused="true"] * { animation-play-state: paused !important; }
@media (prefers-reduced-motion: reduce) { .hpo-morph, .hpo-spin, .hpo-drop { animation: none; } }
`}</style>
<div
aria-hidden="true"
className="absolute inset-0 opacity-40"
style={{
backgroundImage: "radial-gradient(rgba(255,255,255,0.22) 1px, transparent 1px)",
backgroundSize: "28px 28px",
maskImage: "radial-gradient(ellipse 60% 70% at 70% 50%, black, transparent)",
WebkitMaskImage: "radial-gradient(ellipse 60% 70% at 70% 50%, black, transparent)",
}}
/>
<div className="relative mx-auto grid max-w-6xl items-center gap-10 lg:grid-cols-2 lg:gap-6">
<motion.div initial={reduce ? false : { opacity: 0, y: 28 }} animate={{ opacity: 1, y: 0 }} transition={{ type: "spring", stiffness: 70, damping: 16 }}>
<p className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/5 px-3.5 py-1.5 text-sm font-medium text-emerald-200">
<span className="relative flex h-2 w-2">
<span className="absolute inset-0 animate-ping rounded-full bg-lime-300 motion-reduce:animate-none" />
<span className="relative h-2 w-2 rounded-full bg-lime-300" />
</span>
Verdant for buildings
</p>
<h1 className="mt-6 text-5xl font-semibold leading-[1.04] tracking-tight sm:text-7xl">
See your building <span className="bg-gradient-to-r from-lime-200 via-emerald-300 to-cyan-300 bg-clip-text text-transparent">breathe.</span>
</h1>
<p className="mt-6 max-w-md text-lg leading-relaxed text-zinc-300">
Verdant turns every meter, sensor and solar panel into one living picture, so wasted energy has nowhere left to hide.
</p>
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
<a href="#" className={`inline-flex items-center justify-center rounded-full bg-lime-300 px-7 py-3.5 text-sm font-semibold text-zinc-950 shadow-lg shadow-lime-300/25 transition hover:bg-lime-200 ${ring}`}>
Book a walkthrough
</a>
<a href="#" className={`inline-flex items-center justify-center rounded-full border border-white/20 px-7 py-3.5 text-sm font-semibold text-white transition hover:bg-white/10 ${ring}`}>
Open the live demo
</a>
</div>
<p className="mt-8 text-sm text-zinc-400">Running in 240 buildings across nine cities.</p>
</motion.div>
<div aria-hidden="true" className="relative mx-auto aspect-square w-full max-w-xs sm:max-w-lg">
{/* A soft glow that trails far behind the orb. */}
<motion.div className="absolute inset-[8%] rounded-full bg-emerald-500/40 blur-3xl" style={{ x: glowX, y: glowY }} />
<motion.div
className="absolute inset-[10%]"
style={{ x: orbX, y: orbY, scaleX: stretchX, scaleY: stretchY }}
initial={reduce ? false : { opacity: 0, scale: 0.3 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ type: "spring", stiffness: 60, damping: 11, delay: 0.1 }}
>
<div className="hpo-morph relative h-full w-full overflow-hidden shadow-[0_0_120px_10px_rgba(16,185,129,0.45)]">
<motion.div className="absolute inset-0" style={{ filter: tint }}>
<div
className="hpo-spin absolute -inset-1/3 blur-2xl"
style={{ backgroundImage: "conic-gradient(from 0deg, #10b981, #22d3ee, #a3e635, #0d9488, #facc15, #10b981)" }}
/>
</motion.div>
<motion.div className="absolute inset-0" style={{ backgroundImage: shine }} />
<div className="absolute inset-0" style={{ backgroundImage: "radial-gradient(circle at 72% 78%, rgba(4,20,16,0.75), rgba(4,20,16,0) 55%)" }} />
</div>
<div className="hpo-drop absolute inset-0">
<span className="absolute left-1/2 top-1/2 -ml-5 -mt-5 h-10 w-10 rounded-full bg-gradient-to-br from-lime-200 to-emerald-500 shadow-[0_0_30px_rgba(163,230,53,0.7)]" />
</div>
</motion.div>
{/* The pile of glass cards in front of the orb. */}
<div className="absolute inset-x-0 bottom-[4%] flex justify-center sm:justify-end" style={{ perspective: "1200px" }}>
<motion.div className="relative h-44 w-64 sm:w-80" style={{ rotateY: pileY, rotateX: pileX }}>
{cards.map((card) => {
const place = order.indexOf(card.id);
const slot = slots[place];
const out = leaving === card.id;
return (
<motion.div
key={card.id}
className="absolute inset-0 overflow-hidden rounded-3xl border border-white/20 bg-white/10 p-5 shadow-2xl shadow-black/50 backdrop-blur-2xl"
style={{ zIndex: out ? 10 : 3 - place }}
initial={reduce ? false : { opacity: 0, y: 120, scale: 0.8 }}
animate={out ? { y: 84, x: -12, scale: 1.03, opacity: 0, rotate: -5 } : { y: slot.y, x: slot.x, scale: slot.scale, opacity: slot.opacity, rotate: 0 }}
transition={{ type: "spring", stiffness: out ? 220 : 130, damping: out ? 24 : 17 }}
>
<span className="absolute inset-x-6 top-0 h-px bg-gradient-to-r from-transparent via-white/70 to-transparent" />
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-xs font-medium text-emerald-100/80">{card.label}</p>
<p className="mt-1 text-2xl font-semibold tracking-tight text-white sm:text-3xl">{card.value}</p>
</div>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-white/15 text-lime-200">
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
<path d={card.glyph} />
</svg>
</span>
</div>
<svg viewBox="0 0 120 36" preserveAspectRatio="none" className="mt-2 h-9 w-full">
<polyline points={card.points} fill="none" stroke="#bef264" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" vectorEffect="non-scaling-stroke" />
</svg>
<p className="mt-1 truncate text-xs text-emerald-50/80">{card.note}</p>
</motion.div>
);
})}
</motion.div>
</div>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/hero-with-pointer-reactive-morphing-orb-and-glass-card-stack.json
It is a single square element whose eight `border-radius` values are animated between four sets of percentages. Uneven radii on each corner give a blob outline, and easing between sets makes it look like liquid. A conic gradient larger than the orb rotates inside it, clipped by `overflow-hidden`.
The section records the pointer position as two numbers from -1 to 1. Springs smooth those numbers, and the orb, its highlight and the glow each read them at a different strength, which creates depth. The orb's speed is read with a velocity hook and turned into a small stretch on one axis and a squeeze on the other.
A frame callback feeds the same two numbers from a slow sine and cosine path whenever the pointer has been still for about two seconds. That covers phones, keyboards and idle visitors, and it stops when the section is off screen.
Each card has a translucent white fill, a light border, a one pixel highlight along the top edge and `backdrop-blur-2xl`, which blurs whatever is behind it. The effect depends on something colourful sitting behind the card, which is the orb's job here.
Three blurred cards are fine on current phones. If you need to support older devices, remove `backdrop-blur-2xl` from the two rear cards or raise the fill to `bg-white/20`.
Related: Hero with Floating Glass Shapes, Gradient Mesh Background with Flowing Blobs and Grain, Draggable Card Stack Carousel with Spring Physics.
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.