freeNew236 lines · framer-motion
Hero Mesh Gradient
A gradient that breathes, a headline that drops in. A full-width landing hero with a soft animated mesh-gradient backdrop (drifting blurred colour blobs on a 2D canvas) under a kinetic, word-by-word headline and two CTAs.
- Use it for
- The first screen of an AI product, SaaS launch page or app landing, where the headline needs to feel alive without a heavy asset.
- Themes
- Light + dark
- License
- Commercial use (draft)
Live
New · AI product launch
Shiptheproductpeopleremember
A landing hero that moves like the rest of your product. Swap the copy, keep the motion.
tsx
"use client";
import * as React from "react";
import { motion, useReducedMotion } from "framer-motion";
import { cn } from "@/lib/utils";
/**
* Full-width SaaS hero: an animated shader-like mesh gradient (2D canvas, blurred
* radial blobs drifting on independent orbits) behind a big kinetic headline that
* drops in word by word and drifts gently on a slow loop.
*/
export interface HeroMeshGradientProps {
eyebrow?: string;
/** Headline text. The last word is painted with the signal colour. */
headline?: string;
subcopy?: string;
primaryCta?: string;
secondaryCta?: string;
/** Overall animation speed multiplier. */
speed?: number;
/** Blob size / glow strength. */
intensity?: number;
className?: string;
}
type RGB = [number, number, number];
function readColor(name: string, fallback: string): RGB {
if (typeof window === "undefined") return hexToRgb(fallback);
const raw = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return hexToRgb(raw || fallback);
}
function hexToRgb(hex: string): RGB {
const h = hex.replace("#", "");
const v = h.length === 3 ? h.split("").map((c) => c + c).join("") : h;
const n = parseInt(v, 16);
if (Number.isNaN(n) || v.length < 6) return [255, 90, 31];
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
export function HeroMeshGradient({
eyebrow = "New · AI product launch",
headline = "Ship the product people remember",
subcopy = "A landing hero that moves like the rest of your product. Swap the copy, keep the motion.",
primaryCta = "Get started",
secondaryCta = "See how it works",
speed = 1,
intensity = 1,
className,
}: HeroMeshGradientProps) {
const reduce = useReducedMotion();
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const hostRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
const canvas = canvasRef.current;
const host = hostRef.current;
if (!canvas || !host) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
let raf = 0;
let visible = true;
let w = 0;
let h = 0;
let dpr = Math.min(window.devicePixelRatio || 1, 2);
const blobs = [
{ cx: 0.2, cy: 0.3, rx: 0.42, ry: 0.5, fx: 0.21, fy: 0.17, phase: 0, var: "--signal", fallback: "#ff5a1f" },
{ cx: 0.75, cy: 0.25, rx: 0.4, ry: 0.46, fx: 0.16, fy: 0.23, phase: 2.1, var: "--info", fallback: "#2952ff" },
{ cx: 0.5, cy: 0.75, rx: 0.5, ry: 0.4, fx: 0.19, fy: 0.14, phase: 4.4, var: "--lime", fallback: "#c8f03c" },
{ cx: 0.85, cy: 0.8, rx: 0.32, ry: 0.36, fx: 0.24, fy: 0.2, phase: 1.3, var: "--ok", fallback: "#138a4b" },
];
let colors: RGB[] = blobs.map((b) => readColor(b.var, b.fallback));
let dark = false;
const applyTheme = () => {
dark = document.documentElement.classList.contains("dark");
colors = blobs.map((b) => readColor(b.var, b.fallback));
};
applyTheme();
const mo = new MutationObserver(applyTheme);
mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
const resize = () => {
w = host.clientWidth;
h = host.clientHeight;
dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.max(1, Math.round(w * dpr));
canvas.height = Math.max(1, Math.round(h * dpr));
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
};
resize();
const ro = new ResizeObserver(resize);
ro.observe(host);
const io = new IntersectionObserver(([e]) => (visible = e.isIntersecting));
io.observe(host);
const draw = (t: number) => {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = "rgba(0,0,0,0)";
const base = dark ? 0.55 : 0.4;
ctx.globalCompositeOperation = dark ? "lighter" : "multiply";
ctx.filter = "blur(2px)";
blobs.forEach((b, i) => {
const x = (b.cx + Math.sin(t * b.fx + b.phase) * 0.12) * w;
const y = (b.cy + Math.cos(t * b.fy + b.phase) * 0.12) * h;
const r = Math.max(w, h) * b.rx * (0.85 + intensity * 0.3);
const [cr, cg, cb] = colors[i];
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, `rgba(${cr},${cg},${cb},${base * intensity})`);
g.addColorStop(1, `rgba(${cr},${cg},${cb},0)`);
ctx.fillStyle = g;
ctx.beginPath();
ctx.ellipse(x, y, r, r * (b.ry / b.rx), 0, 0, Math.PI * 2);
ctx.fill();
});
ctx.filter = "none";
ctx.globalCompositeOperation = "source-over";
};
let elapsed = 0;
let last = performance.now();
const loop = () => {
raf = requestAnimationFrame(loop);
if (!visible || document.hidden) return;
const now = performance.now();
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
elapsed += dt * speed;
draw(elapsed);
};
if (reduce) {
draw(0.6);
} else {
loop();
}
return () => {
cancelAnimationFrame(raf);
ro.disconnect();
io.disconnect();
mo.disconnect();
};
}, [speed, intensity, reduce]);
const words = headline.split(" ");
return (
<section className={cn("relative isolate overflow-hidden bg-background", className)}>
<div ref={hostRef} className="absolute inset-0">
<canvas ref={canvasRef} className="absolute inset-0" aria-hidden />
</div>
<div
className="absolute inset-0 [background-image:radial-gradient(circle_at_1px_1px,var(--border)_1px,transparent_1px)] [background-size:28px_28px] opacity-[0.35] [mask-image:radial-gradient(ellipse_80%_60%_at_50%_30%,black,transparent_75%)]"
aria-hidden
/>
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-background/10 to-background" aria-hidden />
<div className="relative mx-auto flex min-h-[32rem] max-w-5xl flex-col items-center px-4 pb-20 pt-20 text-center sm:min-h-[38rem] sm:px-6 sm:pt-28">
<motion.span
initial={reduce ? false : { opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className="inline-flex items-center gap-2 rounded-full border border-border bg-card/80 px-3 py-1.5 font-mono text-[11px] uppercase tracking-[0.16em] text-muted-foreground backdrop-blur"
>
<span className="size-1.5 rounded-full bg-signal" aria-hidden />
{eyebrow}
</motion.span>
<h1 className="mt-6 font-display text-[clamp(2.6rem,7.5vw,5.75rem)] font-extrabold leading-[0.95] tracking-[-0.045em]">
{words.map((word, i) => (
<span key={`${word}-${i}`} className="mr-[0.26em] inline-block overflow-hidden pb-[0.08em] align-bottom last:mr-0">
<motion.span
className={cn("inline-block", i === words.length - 1 && "text-signal")}
initial={reduce ? false : { y: "115%", rotate: 6 }}
animate={
reduce
? { y: "0%" }
: { y: ["115%", "0%", "0%"], rotate: [6, 0, 0] }
}
transition={{ duration: 0.6, delay: 0.08 * i, ease: [0.16, 1, 0.3, 1] }}
>
<motion.span
className="inline-block"
animate={reduce ? undefined : { y: [0, -6, 0] }}
transition={{ duration: 4 + (i % 3), repeat: Infinity, repeatType: "mirror", ease: "easeInOut", delay: i * 0.15 }}
>
{word}
</motion.span>
</motion.span>
</span>
))}
</h1>
<motion.p
initial={reduce ? false : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.35 }}
className="mt-5 max-w-xl text-lg leading-relaxed text-muted-foreground"
>
{subcopy}
</motion.p>
<motion.div
initial={reduce ? false : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.45 }}
className="mt-8 flex flex-wrap items-center justify-center gap-3"
>
<a
href="#"
className="inline-flex h-12 items-center rounded-full bg-primary px-6 text-[15px] font-semibold text-primary-foreground transition hover:opacity-90"
>
{primaryCta} →
</a>
<a
href="#"
className="inline-flex h-12 items-center rounded-full border-2 border-foreground px-6 text-[15px] font-semibold transition hover:bg-foreground hover:text-background"
>
{secondaryCta}
</a>
</motion.div>
</div>
</section>
);
}
export default HeroMeshGradient;
How it behaves
- →Canvas draws 4 blurred radial-gradient blobs on independent orbital paths; colours come from --signal/--info/--lime/--ok and switch instantly when .dark is toggled (MutationObserver on <html>).
- →Blend mode is lighter in dark mode and multiply in light mode so the gradient looks saturated on both themes.
- →Headline splits into words; each drops in with a spring on mount, then floats on a slow infinite loop; the last word is painted in the signal colour.
- →speed scales the blob orbit speed; intensity scales blob size and opacity.
- →Pauses the animation loop when the hero scrolls offscreen (IntersectionObserver) or the tab is hidden.
- →prefers-reduced-motion draws one static gradient frame and skips the headline drop/float animation.
Accessibility
- ✓Canvas and the dot-grid overlay are aria-hidden; only real text and links are in the accessibility tree.
- ✓Headline is a single <h1>; CTAs are real <a> elements with visible focus rings from the design system.
- ✓Contrast of headline and body text against the gradient is kept high by layering a background/background-gradient scrim under the text column.
- ✓Honours prefers-reduced-motion.