Generative WebGL Landing on React Three Fiber: When the Product Site Is the Product#
Most app landing pages look the same: a phone-framed screenshot, three feature cards, an "Download on the App Store" button. It works, but it doesn't stick. When I built meteohealth.pro — the marketing site for MeteoHealth, an iOS app that tracks weather sensitivity — I wanted to test a different hypothesis: what if the site itself became a demonstration of the product, rather than a description of it?
The idea is simple to state and hard to execute: the hero section holds a generative "pressure field" — a wavy pattern built on React Three Fiber that reacts in real time to actual weather data for the selected city. When the pressure drop grows, the pattern thickens and grows agitated. When the weather is calm, the field breathes evenly. There's no noise for the sake of noise — every shader deformation answers to a real number coming from the server.
This article walks through how it's built: the React Three Fiber architecture, a breakdown of the shader itself, and the degradation system that has to hold up equally well on a flagship iPhone and on a budget Android with prefers-reduced-motion turned on.
When the Product Site Is the Product#
It's tempting to treat a WebGL scene on a landing page as "just a nice background." That's the mistake that usually kills experiments like this: decorative animation competes with copy for attention, drags down LCP, and has nothing to do with what the page is actually selling.
The alternative is to make the visualization literal proof of what the product does. MeteoHealth analyzes the link between atmospheric pressure drops and how people feel. So the hero scene isn't an abstract pattern — it's a direct rendering of that exact pressure delta: pressureDelta24h from the weather server becomes the single "nerve" driving the whole field. It's not an illustration of a feature; it's a working slice of the same logic the app runs, just seen through shapes instead of numbers in a table.
That leads to an architectural rule: the scene shouldn't have decorative variables. If a shader parameter doesn't correspond to real data, it shouldn't exist. That constraint disciplines the design far more than any creative brief could.
Anatomy of a Generative Field: R3F, Three.js and Data Instead of Noise#
React Three Fiber isn't an alternative to Three.js — it's a declarative layer on top of it: the scene is described as a JSX tree, while the render loop, resource disposal, and syncing with React state are handled by R3F's reconciler. For generative, data-driven graphics, that gives you the thing that matters most: a component boundary between the "data layer" (React state, fetching, mapping weather into shader parameters) and the "render layer" (useFrame, direct uniform mutations).
The meteohealth.pro scene is built from two layers stacked on top of each other:
- Pressure isolines — a full-screen plane with a fragment shader drawing concentric wavy lines, similar to a topographic map. Every third line is an accent (green); the rest are low-alpha "ink" tone.
- GPU particles along the same isolines — thin trails rotated 90° relative to the field gradient (mimicking geostrophic wind, the way real meteorology works), simulated through a ping-pong FBO entirely on the GPU, with zero per-particle JS loop on the CPU.
The key architectural decision is to drive both systems from one and the same scalar "field agitation" value, rather than from arbitrary time. Below is a simplified but functionally faithful sketch of how this is assembled in React Three Fiber.
// components/field/PressureField.tsx
'use client'
import { Suspense, lazy, useEffect, useState } from 'react'
import { useReducedMotion } from '@/lib/hooks/use-reduced-motion'
const FieldScene = lazy(() => import('./FieldScene'))
type Tier = 'A' | 'B' | 'C'
function detectTier(prefersReducedMotion: boolean): Tier {
if (prefersReducedMotion) return 'C'
if (!('WebGLRenderingContext' in window)) return 'C'
const memory = (navigator as any).deviceMemory ?? 4
const isTouchHeavy = navigator.maxTouchPoints > 4
if (memory <= 2 || isTouchHeavy) return 'B'
return 'A'
}
export function PressureField({ posterSrc }: { posterSrc: string }) {
const prefersReducedMotion = useReducedMotion()
const [tier, setTier] = useState<Tier | null>(null)
useEffect(() => {
setTier(detectTier(prefersReducedMotion))
}, [prefersReducedMotion])
if (tier === null || tier === 'C') {
return <img src={posterSrc} alt="" aria-hidden className="field-poster" />
}
return (
<Suspense fallback={<img src={posterSrc} alt="" aria-hidden className="field-poster" />}>
<FieldScene tier={tier} />
</Suspense>
)
}Notice that the canvas doesn't appear until a tier is decided. The poster isn't a "just in case" fallback — it's a full, legitimate first state of the screen. It's the poster, not the WebGL canvas, that's responsible for LCP.
Code: From Canvas to Shader#
Next comes the material component, wiring weather data into the shader through shaderMaterial from @react-three/drei. The formula that translates a pressure drop into "field agitation" is deliberately non-linear: a 7 hPa drop produces a noticeable but not jarring response, and the curve saturates afterward so the field never spirals into visual chaos at extreme values.
// components/field/FieldMaterial.tsx
import { shaderMaterial } from '@react-three/drei'
import { extend, useFrame } from '@react-three/fiber'
import { useRef } from 'react'
import vertexShader from './field.vert.glsl'
import fragmentShader from './field.frag.glsl'
export const FieldMaterial = shaderMaterial(
{
uTime: 0,
uAgitation: 0, // 0..~0.97, grows with a 24h pressure drop
uIsoDensity: 14,
uInk: [0.06, 0.07, 0.07],
uGreen: [0.12, 0.48, 0.35],
},
vertexShader,
fragmentShader
)
extend({ FieldMaterial })
export function agitationFromPressureDelta(deltaHpa24h: number): number {
// A pressure drop drives the field; a rise or stability keeps it calm
const drop = Math.max(0, -deltaHpa24h)
return 1 - Math.exp(-drop / 7)
}
export function FieldMesh({ pressureDelta24h }: { pressureDelta24h: number }) {
const materialRef = useRef<any>(null)
const targetAgitation = agitationFromPressureDelta(pressureDelta24h)
useFrame((state, delta) => {
if (!materialRef.current) return
materialRef.current.uTime = state.clock.elapsedTime
// Smooth lerp toward the target value — no jumps on data refresh
materialRef.current.uAgitation +=
(targetAgitation - materialRef.current.uAgitation) * Math.min(1, delta * 2)
})
return (
<mesh scale={[2, 2, 1]}>
<planeGeometry args={[1, 1]} />
{/* @ts-expect-error – extended material via shaderMaterial */}
<fieldMaterial ref={materialRef} transparent />
</mesh>
)
}And here's the fragment shader itself — a simplified version of the logic that actually draws the isolines:
// field.frag.glsl
uniform float uTime;
uniform float uAgitation; // 0 (calm) .. ~0.97 (strong pressure drop)
uniform float uIsoDensity; // base isoline density
uniform vec3 uInk;
uniform vec3 uGreen;
varying vec2 vUv;
float snoise(vec2 v); // 2D simplex noise, implementation omitted
void main() {
vec2 uv = vUv * 2.0 - 1.0;
// The field "breathes" on two noise octaves; the second one grows
// with agitation and is barely noticeable in calm weather
float breathing =
snoise(uv * 1.6 + uTime * 0.075) * 0.5 +
snoise(uv * 3.7 - uTime * 0.028) * (0.2 + uAgitation * 0.3);
float density = uIsoDensity * (0.82 + uAgitation * 0.55);
float field = length(uv) * density + breathing * (0.055 + uAgitation * 0.05);
// Isolines: distance to the nearest "line" in the field's modulus
float line = abs(fract(field) - 0.5) * 2.0;
float iso = 1.0 - smoothstep(0.0, fwidth(field) * 1.5, line);
// Every third line is an accent, the rest are "ink" toned
bool isAccent = mod(floor(field), 3.0) < 1.0;
vec3 color = isAccent ? uGreen : uInk;
float alpha = iso * (isAccent ? 0.55 : 0.24);
gl_FragColor = vec4(color, alpha);
}Note the fwidth(field) used for anti-aliasing the lines — it's what turns the shader from a "jagged mosaic" into clean, thin lines at any resolution and devicePixelRatio, without an extra post-process pass.
Canvas 2D, SVG, or WebGL — What to Pick#
Before writing a single shader, it's worth honestly asking whether WebGL is needed at all. For most "lively" landing pages, Canvas 2D or even SVG with CSS animation is enough. WebGL earns its keep only when three conditions hold at once: you need a large number of moving primitives (hundreds to thousands of particles), you need sub-pixel smoothness on requestAnimationFrame, and the graphic has to react to continuously changing data without rebuilding the DOM.
| Criterion | Canvas 2D | SVG | WebGL (R3F / Three.js) |
|---|---|---|---|
| Number of animated objects | Hundreds, FPS drops after that | Tens — DOM is expensive | Thousands of particles on GPU |
| Reacting to live data | Manual re-draw needed | Simple (attributes/CSS variables) | Simple (shader uniforms) |
| Accessibility / SEO | Content invisible to screen readers | Natively accessible, indexable | Requires HTML text duplicates |
| Learning curve | Low | Very low | High (shaders, buffers) |
| Degradation on weak devices | Good | Excellent | Needs an explicit fallback tier |
| Typical use case | Charts, simple particles | Icons, diagrams, dash-draw schematics | Generative scenes, field simulations |
For meteohealth.pro, choosing WebGL was a deliberate trade-off: the scene is the only "expensive" element on the entire site, and the JS budget on content pages (blog, features, legal) stays around 100 KB precisely because the three-chunk only loads on the landing page, via dynamic import, and only after idle.
Degradation Without Compromise: Tiers, Posters, and prefers-reduced-motion#
The most common mistake with generative landing pages is testing them only on the developer's MacBook Pro. Real visitors show up on three-year-old budget Android phones, in low-power mode, with prefers-reduced-motion turned on. A system that only looks good under ideal conditions isn't ready for production.
On meteohealth.pro, degradation is built as three explicit tiers, not a single "turn off animation" flag:
- Tier A — the full scene: both noise octaves, dense isolines, the full particle set,
devicePixelRatioup to 1.75. - Tier B — a simplified scene: fewer particles drawn (
drawRange), one noise octave,devicePixelRatiocapped at 1.25. - Tier C — a static AVIF poster captured from the real scene. It doubles as the base for the OG image — not a throwaway placeholder, but an honest frame of the actual product.
Tier selection isn't a one-time decision: alongside the initial heuristic (deviceMemory, maxTouchPoints), the app measures actual frame time over the first 20 frames and drops a tier if rendering consistently blows the budget. The same thing happens live during the session — a rolling 120-frame window, and if the average frame time exceeds roughly 33ms, the scene steps down a tier instead of grinding the GPU until the phone overheats.
prefers-reduced-motion is handled separately and takes priority — it isn't "just another performance signal," it's an explicit user decision. When the flag is set, Three.js, GSAP, and Lenis aren't loaded at all — not just disabled, but never requested over the network in the first place. The canvas fades in over the poster with a 0.7-second crossfade after the first frame is ready — the swap itself shouldn't read as a jarring UI jump.
The Design System as a Contract: GSAP, Lenis and Liquid Signal#
The WebGL scene is only one element, however prominent. It lives inside its own design system, "Liquid Signal": calm editorial typography paired with HUD primitives — monospace readouts like PRESSURE_Δ24H, TIME_CODE, RISK sitting next to genuinely real readings, never decorative numbers. The idea is that the site behaves like a calibrated instrument rather than an ad banner: every number on screen is real, including the one driving the generative field.
GSAP and Lenis are only loaded on the landing page and do exactly two things: a single short load sequence on first visit (lines draw in, HUD labels "type out"), and scroll reveals for the rest of the content (translateY plus fade, roughly 0.6s, power2.out). On content pages — the blog, feature pages, legal text — there's either no animation at all or CSS-only transitions: the JS budget for those pages shouldn't depend on whatever's happening on the landing page.
The result turned out to be surprisingly strict for a "generative" project: the bolder the signature element, the more disciplined everything around it has to be. One scene carries all the site's artistic boldness; everything else stays quiet, predictable, and never flickers.
Conclusion#
A generative WebGL scene earns its place on a landing page only when it isn't decoration but a direct rendering of product data — and only if the degradation engineering is thought through as carefully as the shader itself. React Three Fiber gives you a convenient declarative boundary between data and render, but the discipline lives in the details: one agitation parameter instead of a handful of magic numbers, explicit tiers instead of a single animation on/off flag, and prefers-reduced-motion treated as a user decision rather than a performance setting.
You can see it live at meteohealth.pro, the marketing site for MeteoHealth: open it on a phone with prefers-reduced-motion on, and the poster still reads as a deliberate design choice, not a placeholder.



