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

PropTypeDefaultDescription
classNamestring-Extra classes on the shader host element.
speednumber1.2Animation 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.
distortionnumber0.45Power of the organic noise distortion, 0–1. Kept low so colour spots blend over a wide area instead of meeting at a visible edge.
swirlnumber0.15Power of the vortex distortion, 0–1. Kept low — high values bend the colour boundaries into visible chevron shapes across the hero.
colorVarsreadonly string[]--hero-mesh-1 … 4CSS 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

tsx
{/* 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

tsx
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