Skip to content
loopkit
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.