Hero Background
The visual backdrop of the homepage hero — a WebGL mesh-gradient shader in the brand blues, layered with a static fallback, a dark vignette, a mobile scrim, and a bottom fade.
Base layer (no WebGL)
.hero-mesh-base is a quiet deep-navy field beneath HeroMeshGradient. It shows before the shader mounts, when WebGL is unavailable, or on context loss. It deliberately does not imitate the shader — the shader animates, so a static layer can only ever match one frame of it, and every near-miss read as a glitch at the handover. The shader cross-fades in on top instead, so the hero simply lights up.
Mesh gradient shader
HeroMeshGradient renders an animated WebGL mesh gradient. Colours are read at mount from the --hero-mesh-* tokens, so the palette stays in globals.css. The shader library pauses rendering off-screen and in background tabs on its own.
Vignette + scrim
.hero-mesh-vignette darkens the area behind the headline. Below lg the hero is a single centred column, so the mask is centred and sits high to cover the stacked headline; from lg up it returns to the middle of the section. .hero-mesh-scrim adds a top-down darkening under the nav and is applied with lg:hidden.
Bottom fade
.fade-to-background is a linear-to-bottom gradient that blends the hero into the page background beneath it. Applied to the bottom 40% of the hero.
HeroMeshGradient props
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | - | Extra classes on the shader host element. |
speed | number | 1.2 | Animation speed — the frame counter advances by 1000 × speed per second, so 1.2 gives a clearly different composition roughly every 2.5s. Forced to 0 when the visitor prefers reduced motion. Does not affect frame rate or GPU cost. |
distortion | number | 0.45 | Power of the organic noise distortion, 0–1. Kept low so colour spots blend over a wide area instead of meeting at a visible edge. |
swirl | number | 0.15 | Power of the vortex distortion, 0–1. Kept low — high values bend the colour boundaries into visible chevron shapes across the hero. |
colorVars | readonly string[] | --hero-mesh-1 … 4 | CSS custom properties the colour stops are read from at mount. |
Colour tokens
--hero-mesh-1 … --hero-mesh-4 are a navy → blue ramp in roughly even luminance steps, ending on --accent-deep and --accent.
There is deliberately no near-black stop. A black spot next to the bright stop produces a hard seam that reads as a chevron cutting across the hero. The darkness comes from .hero-mesh-vignette and .fade-to-background instead — so don't “fix” stop 1 back to --background.
These four tokens must stay literal hex. They are read with getPropertyValue() and passed to WebGL, which cannot parse color-mix(), var() or oklch(). Converting one to a color-mix() makes the shader fall back to grey.
CSS classes
.hero-mesh-base
Quiet deep-navy ground under the shader. Renders before the shader mounts and when WebGL is unavailable. Does not imitate the shader — the shader cross-fades in over it.
.hero-mesh-vignette
Radial dark mask over the shader. Centred and sitting high on mobile and tablet, centred mid-section from lg up. Distinct from .hero-vignette, which the footer uses.
.hero-mesh-scrim
Top-down darkening beneath the nav. Mobile and tablet only, via lg:hidden.
.fade-to-background
Linear bottom fade from transparent → background. Applied to the bottom 40% of the hero.
Entrance
The shader cross-fades in over .hero-mesh-base across 1.4s. The reveal is gated on the canvas having a real drawing buffer — not on React mounting, and not merely on the <canvas> existing. The library inserts that element before it has sized or rendered it, so an earlier version faded an empty 0×0 canvas to full opacity and the shader then cut in abruptly. Don't simplify this back to a mount-time fade.
A timeout backstops the reveal, because requestAnimationFrame is suspended while the document is hidden — without it a hero loaded in a background tab would stay pinned at zero opacity.
Reduced motion
Under prefers-reduced-motion: reduce the shader freezes on a still frame rather than falling back to CSS — speed drops to 0 while the composition stays fully rendered. The preference is watched live, so toggling it resumes the animation without a reload.
Usage
{/* Background layers — stack order matters */}
<section className="relative overflow-hidden">
{/* Static base — shows before the shader mounts and if WebGL is absent */}
<div className="hero-mesh-base pointer-events-none absolute inset-0 z-0" />
{/* Animated WebGL mesh gradient */}
<div className="pointer-events-none absolute inset-0 z-[1]">
<HeroMeshGradient />
</div>
{/* Vignette + mobile scrim */}
<div className="hero-mesh-vignette pointer-events-none absolute inset-0 z-[2]" />
<div className="hero-mesh-scrim pointer-events-none absolute inset-0 z-[3] lg:hidden" />
{/* Bottom fade */}
<div className="fade-to-background pointer-events-none absolute inset-x-0 bottom-0 z-[4] h-[40%]" />
{/* Content above layers */}
<div className="relative z-10">
<h1 className="heading-h1">Title</h1>
</div>
</section>Import
import dynamic from 'next/dynamic'
const HeroMeshGradient = dynamic(
() => import('@/components/ui/HeroMeshGradient').then(mod => ({ default: mod.HeroMeshGradient })),
{ ssr: false }
)Related files
src/components/ui/HeroMeshGradient.tsxsrc/app/globals.css — .hero-mesh-base, .hero-mesh-vignette, .hero-mesh-scrim, .fade-to-backgroundsrc/components/sections/Hero.tsx