# FluidSimulationHero
A full bleed hero on a live GPU Navier Stokes surface, stirred by a pointer that is itself a glass lens.
## 1. Setup
// Requires React 18 or newer (useId). npm install @phosphor-icons/react
One file. 'use client' on line 1 · export default function FluidSimulationHero() · Tailwind CSS v4 (arbitrary values everywhere) · raw WebGL2 with a WebGL1 fallback, no shader library, no 3D engine, no animation library.
import { useEffect, useId, useRef, useState } from 'react'
import { List, PaperPlaneTilt, Sparkle, X } from '@phosphor-icons/react'
## 2. Constants
Everything above the component function, verbatim. The shaders, the palette, the two compositions and the arrival are the component; nothing here is illustrative.
/**
* Fluid Simulation Hero. A full bleed hero driven by a real time Navier Stokes solver
* running on the GPU: advect velocity, advect dye, curl, vorticity confinement,
* divergence, Jacobi pressure, gradient subtract, then a surface normal pass and
* an edge detector over it that place the iridescent thin film. Raw WebGL2 with
* a WebGL1 fallback, no shader library, no 3D engine, all in this one file.
*
* Third party notice at the foot of this file. Keep it with the file.
*/
/* ------------------------------------------------------------------ types */
export type FluidPalette = {
/** Three stop background wash, painted in the display shader at 155 degrees. */
ground: [string, string, string]
vignette: string
vignetteAlpha: number
/** Dye at its densest. */
core: string
/** Dye at working density. */
body: string
/** Dye in the thin trailing filaments. */
tail: string
/** Thin film lip on one side of a filament. */
iridescenceA: string
/** Thin film lip on the other side. */
iridescenceB: string
/** Strength of both lips, 0 to 1. */
iridescence: number
/** Colour the headline veil pushes the type toward. */
veil: string
/** Veil alpha ceiling, 0 to switch the veil off. 0.4 is the contrast safe
* ceiling if it is on. */
veilMax: number
text: string
textSoft: string
watermark: string
pillBg: string
pillFg: string
}
export type FluidNavItem = { label: string; href: string }
/** Every type decision the hero makes, in one object, so the whole thing can be
* refaced without touching the shader half of the file. Sizes are raw CSS
* lengths, so clamp() is allowed anywhere a fixed px would go. */
export type FluidTypography = {
/** Family for all UI text. Empty string inherits the surrounding page font. */
fontFamily: string
/** Family for the oversized decorative watermark only. */
displayFontFamily: string
/** Watermark slant. A family with no real italic gets a browser faked one,
* which leans the glyphs into each other at this size. watermarkTracking is
* what buys the room back; 'normal' is the other way out. */
watermarkStyle: 'italic' | 'normal'
/** Letter spacing on the watermark. It carries more of the look than it
* sounds like: at 460px every 0.01em is another 4.6px between glyphs. */
watermarkTracking: string
eyebrowSize: string
headlineSize: string
headlineWeight: number
headlineTracking: string
headlineLeading: number
sublineSize: string
ctaSize: string
hintSize: string
wordmarkSize: string
navSize: string
watermarkSize: string
}
/** The arrival. The fluid is not faded in from a picture that already existed
* off screen; it is ignited on the first frame and grows into the resting mass
* while the type lands on the same clock. Both halves are timed from here. */
export type FluidIntro = {
/** Seconds the fluid takes to grow from one point into the resting mass.
* 0 skips the bloom and opens on the settled composition. */
duration: number
/** Outward kick on the ignition frame, as a multiple of the running spawner
* velocity. This is what throws the first filaments clear of the centre. */
burst: number
/** How far past the resting radius the ring swells at the middle of the
* bloom before easing back into it. 1 is no overshoot. */
overshoot: number
/** Seconds from mount before the first text element arrives. */
textDelay: number
/** Seconds between one arriving text element and the next. */
textStagger: number
}
/** Where the mass sits and how big it is, per frame width. Coordinates are UV
* on the canvas: x runs left to right, y runs BOTTOM to top, so 0.7 is high in
* the frame. The radius is in short edge units and the visible mass ends up
* roughly three times it across. */
export type FluidAnchor = { x: number; y: number; r: number }
/** The two compositions. A phone is not a small desktop: a wide frame has room
* to put the type beside the mass, a narrow one has to stack them. Both halves
* switch on the SAME number, the 768px the text block's own classes use, so
* the fluid and the type can never disagree about which composition is live. */
export type FluidComposition = {
/** Used from 768px of canvas width up, where the type sits beside the mass. */
wide: FluidAnchor
/** Used below 768px, where the composition stacks. */
narrow: FluidAnchor
}
export type FluidSimulationHeroProps = {
headline?: string[]
eyebrow?: string
subline?: string
hint?: string
wordmark?: string
watermark?: string
navItems?: FluidNavItem[]
wordmarkHref?: string
ctaLabel?: string
onCtaClick?: () => void
palette?: FluidPalette
typography?: FluidTypography
intro?: FluidIntro
composition?: FluidComposition
chromeInsetTop?: string
chromeInsetSide?: string
simResolution?: number
dyeResolution?: number
curl?: number
densityDissipation?: number
velocityDissipation?: number
pressureIterations?: number
splatRadius?: number
splatForce?: number
intensity?: number
normalEpsilon?: number
iridescenceStrength?: number
spawnAmplification?: number
spawnVelocity?: number
className?: string
}
/* --------------------------------------------------------------- defaults */
const PALETTE: FluidPalette = {
ground: ['#C9C4E8', '#DCD3EC', '#E8D8E6'],
vignette: '#B7B1DC',
vignetteAlpha: 0.14,
core: '#FFFDFF',
body: '#F5B8D8',
tail: '#F0C8E0',
iridescenceA: '#FF4FD8',
iridescenceB: '#4FE8FF',
iridescence: 0.35,
veil: '#FFFFFF',
// Off. The veil is the dye washing over the headline; the h1 carries no
// background at all. Raise it to bring the effect back, and keep it at or
// under 0.4 if you do, which is where the headline still clears 4.5:1 on the
// brightest frame.
veilMax: 0,
text: '#141218',
// 0.73, not 0.72: the stir hint sits bottom right, over the darkest stop of
// the 155 degree wash with the vignette at full strength, and 0.72 measures
// 4.47:1 there. This clears every position on the frame at 4.58:1.
textSoft: 'rgba(42,36,52,0.73)',
watermark: 'rgba(90,75,120,0.08)',
pillBg: '#141218',
pillFg: '#F4EFFA',
}
// The two responsive bumps this scale replaced (subline 14 -> 16px at 768,
// wordmark 14 -> 16px at 480) are written as clamps whose vw slope hits the
// upper value at exactly that width, so both breakpoints land where they did.
const TYPO: FluidTypography = {
fontFamily: '',
displayFontFamily: 'Georgia, "Times New Roman", ui-serif, serif',
watermarkStyle: 'italic',
watermarkTracking: '0.08em',
eyebrowSize: '12px',
headlineSize: 'clamp(30px, 6.4vw, 84px)',
headlineWeight: 500,
headlineTracking: '-0.03em',
headlineLeading: 0.98,
sublineSize: 'clamp(14px, 2.084vw, 16px)',
ctaSize: '14px',
hintSize: '12px',
wordmarkSize: 'clamp(14px, 3.34vw, 16px)',
navSize: '14px',
watermarkSize: 'clamp(120px, 34vw, 460px)',
}
// The arrival, in seconds. Longer than a normal entrance on purpose: the point
// is to watch the surface come into being, and under about 1.6s the bloom reads
// as a pop rather than as growth. The type is deliberately late, so the fluid
// has the frame to itself for a beat before anything asks to be read.
const INTRO: FluidIntro = {
duration: 2.4,
burst: 3.2,
overshoot: 1.22,
textDelay: 0.34,
textStagger: 0.085,
}
const NAV: FluidNavItem[] = [
{ label: 'Work', href: '#' },
{ label: 'Studio', href: '#' },
{ label: 'Contact', href: '#' },
]
// The resting composition: ONE contained mass, positioned to stay clear of the
// headline block. Seed, spawner and ignition all derive from these numbers, so
// the boot picture and the running picture can never drift apart.
//
// Two of them, because a phone is not a small desktop. A wide frame has room to
// put the type BESIDE the mass, so the mass hangs in the upper right and the
// block sits left of it. A narrow frame has no beside: it is a column, so the
// composition stacks, the mass takes the top and the block sits under it.
//
// The narrow anchor carries a larger radius because the short edge is now the
// frame's width: at the wide value the mass reads as a smudge on a 375px screen
// rather than as the subject.
const COMPOSITION: FluidComposition = {
wide: { x: 0.78, y: 0.66, r: 0.08 },
narrow: { x: 0.52, y: 0.7, r: 0.13 },
}
// Where the composition stops being able to put the type beside the mass. This
// is a LAYOUT fact, so both halves have to read the same one: the text block
// switches placement on the matching min-[768px] classes further down, and the
// two must be changed together. Deciding it on the canvas ASPECT instead, which
// is what this used to do, splits them on any frame that is wide in pixels but
// tall in shape: a portrait tablet took the stacked anchor while its type was
// still using the beside placement, and the mass landed on the headline.
const STACK_BREAKPOINT = 768
// The grid short edge every force default below is tuned against. Velocity is
// stored in texels per second, so forces are rescaled against the live grid and
// simResolution or a quality tier changes detail, never speed.
const SIM_REF = 128
/* --------------------------------------------------------------- shaders */
const VERT = `
precision highp float;
attribute vec2 aPosition;
varying vec2 vUv, vL, vR, vT, vB;
uniform vec2 texelSize;
void main () {
vUv = aPosition * 0.5 + 0.5;
vL = vUv - vec2(texelSize.x, 0.0);
vR = vUv + vec2(texelSize.x, 0.0);
vT = vUv + vec2(0.0, texelSize.y);
vB = vUv - vec2(0.0, texelSize.y);
gl_Position = vec4(aPosition, 0.0, 1.0);
}`
const CLEAR = `
precision mediump float; precision mediump sampler2D;
varying vec2 vUv; uniform sampler2D uTexture; uniform float value;
void main () { gl_FragColor = value * texture2D(uTexture, vUv); }`
const SPLAT = `
precision highp float; precision highp sampler2D;
varying vec2 vUv;
uniform sampler2D uTarget; uniform float aspectRatio; uniform vec3 color;
uniform vec2 point; uniform float radius;
void main () {
vec2 p = vUv - point.xy;
p.x *= aspectRatio;
vec3 splat = exp(-dot(p, p) / radius) * color;
vec3 base = texture2D(uTarget, vUv).xyz;
gl_FragColor = vec4(base + splat, 1.0);
}`
// Semi Lagrangian advection. MANUAL_FILTERING is compiled in when the platform
// cannot filter float textures, so the bilinear tap is done by hand.
const ADVECT = `
precision highp float; precision highp sampler2D;
varying vec2 vUv;
uniform sampler2D uVelocity; uniform sampler2D uSource;
uniform vec2 texelSize; uniform vec2 dyeTexelSize;
uniform float dt; uniform float dissipation;
#ifdef MANUAL_FILTERING
vec4 bilerp (sampler2D sam, vec2 uv, vec2 tsize) {
vec2 st = uv / tsize - 0.5;
vec2 iuv = floor(st);
vec2 fuv = fract(st);
vec4 a = texture2D(sam, (iuv + vec2(0.5, 0.5)) * tsize);
vec4 b = texture2D(sam, (iuv + vec2(1.5, 0.5)) * tsize);
vec4 c = texture2D(sam, (iuv + vec2(0.5, 1.5)) * tsize);
vec4 d = texture2D(sam, (iuv + vec2(1.5, 1.5)) * tsize);
return mix(mix(a, b, fuv.x), mix(c, d, fuv.x), fuv.y);
}
#endif
void main () {
#ifdef MANUAL_FILTERING
vec2 coord = vUv - dt * bilerp(uVelocity, vUv, texelSize).xy * texelSize;
vec4 result = bilerp(uSource, coord, dyeTexelSize);
#else
vec2 coord = vUv - dt * texture2D(uVelocity, vUv).xy * texelSize;
vec4 result = texture2D(uSource, coord);
#endif
gl_FragColor = vec4(dissipation * result.xyz, 1.0);
}`
const DIVERGENCE = `
precision mediump float; precision mediump sampler2D;
varying vec2 vUv, vL, vR, vT, vB; uniform sampler2D uVelocity;
void main () {
float L = texture2D(uVelocity, vL).x;
float R = texture2D(uVelocity, vR).x;
float T = texture2D(uVelocity, vT).y;
float B = texture2D(uVelocity, vB).y;
vec2 C = texture2D(uVelocity, vUv).xy;
if (vL.x < 0.0) { L = -C.x; }
if (vR.x > 1.0) { R = -C.x; }
if (vT.y > 1.0) { T = -C.y; }
if (vB.y < 0.0) { B = -C.y; }
gl_FragColor = vec4(0.5 * (R - L + T - B), 0.0, 0.0, 1.0);
}`
const CURL = `
precision mediump float; precision mediump sampler2D;
varying vec2 vUv, vL, vR, vT, vB; uniform sampler2D uVelocity;
void main () {
float L = texture2D(uVelocity, vL).y;
float R = texture2D(uVelocity, vR).y;
float T = texture2D(uVelocity, vT).x;
float B = texture2D(uVelocity, vB).x;
gl_FragColor = vec4(0.5 * (R - L - T + B), 0.0, 0.0, 1.0);
}`
// Vorticity confinement. This pass is what grows the fine braided filaments.
const VORTICITY = `
precision highp float; precision highp sampler2D;
varying vec2 vUv, vL, vR, vT, vB;
uniform sampler2D uVelocity; uniform sampler2D uCurl;
uniform float curl; uniform float dt;
void main () {
float L = texture2D(uCurl, vL).x;
float R = texture2D(uCurl, vR).x;
float T = texture2D(uCurl, vT).x;
float B = texture2D(uCurl, vB).x;
float C = texture2D(uCurl, vUv).x;
vec2 force = 0.5 * vec2(abs(T) - abs(B), abs(R) - abs(L));
force /= length(force) + 0.0001;
force *= curl * C;
force.y *= -1.0;
vec2 velocity = texture2D(uVelocity, vUv).xy;
velocity += force * dt;
velocity = min(max(velocity, -1000.0), 1000.0);
gl_FragColor = vec4(velocity, 0.0, 1.0);
}`
const PRESSURE = `
precision mediump float; precision mediump sampler2D;
varying vec2 vUv, vL, vR, vT, vB;
uniform sampler2D uPressure; uniform sampler2D uDivergence;
void main () {
float L = texture2D(uPressure, vL).x;
float R = texture2D(uPressure, vR).x;
float T = texture2D(uPressure, vT).x;
float B = texture2D(uPressure, vB).x;
float divergence = texture2D(uDivergence, vUv).x;
gl_FragColor = vec4((L + R + B + T - divergence) * 0.25, 0.0, 0.0, 1.0);
}`
const GRADIENT = `
precision mediump float; precision mediump sampler2D;
varying vec2 vUv, vL, vR, vT, vB;
uniform sampler2D uPressure; uniform sampler2D uVelocity;
void main () {
float L = texture2D(uPressure, vL).x;
float R = texture2D(uPressure, vR).x;
float T = texture2D(uPressure, vT).x;
float B = texture2D(uPressure, vB).x;
vec2 velocity = texture2D(uVelocity, vUv).xy;
velocity -= vec2(R - L, T - B);
gl_FragColor = vec4(velocity, 0.0, 1.0);
}`
// Reads the dye field as a height map and writes its surface normal. The
// magnitude over the dye channels responds to total dye, not to one colour, and
// the epsilon keeps flat water pointing straight up instead of exploding where
// the gradient is zero.
const NORMAL = `
precision highp float; precision highp sampler2D;
varying vec2 vUv, vL, vR, vT, vB;
uniform sampler2D uDye; uniform float uEps;
void main () {
float c = length(texture2D(uDye, vUv).xyz);
float r = length(texture2D(uDye, vR).xyz);
float b = length(texture2D(uDye, vB).xyz);
gl_FragColor = vec4(normalize(vec3(r - c, b - c, uEps)), 1.0);
}`
// Edge detector over the normal map. Bright exactly where the surface bends
// fastest, which is the filament boundary, dark wherever the surface is smooth.
// This, not curl, is what the iridescence rides.
const NORMAL_DELTA = `
precision highp float; precision highp sampler2D;
varying vec2 vUv, vL, vR, vT, vB;
uniform sampler2D uNormal;
void main () {
vec3 c = texture2D(uNormal, vUv).xyz;
vec3 r = texture2D(uNormal, vR).xyz;
vec3 b = texture2D(uNormal, vB).xyz;
gl_FragColor = vec4(abs(r - c) + abs(b - c), 1.0);
}`
// Display pass. The dye field is scalar: .r is density, .g carries an advected
// hue seed so filaments vary along their length. Colour, iridescent lips,
// vignette and the 4x4 Bayer dither all happen here, once, on screen.
const DISPLAY = `
precision highp float; precision highp sampler2D;
varying vec2 vUv;
uniform sampler2D uDye, uNormal, uNormalDelta;
uniform float uAspect;
uniform vec3 uG0, uG1, uG2, uVig, uCore, uBody, uTail, uIriA, uIriB;
uniform float uVigA, uIriAmt;
float bayer2 (vec2 a) { a = floor(a); return fract(dot(a, vec2(0.5, a.y * 0.75))); }
float bayer4 (vec2 a) { return bayer2(0.5 * a) * 0.25 + bayer2(a); }
void main () {
vec4 s = texture2D(uDye, vUv);
float d = s.x;
float hue = clamp(s.y / max(d, 0.0001), 0.0, 1.0);
vec2 dir = vec2(-0.9063, 0.4226);
float t = clamp(dot(vUv - 0.5, dir) + 0.5, 0.0, 1.0);
vec3 ground = t < 0.5 ? mix(uG0, uG1, t * 2.0) : mix(uG1, uG2, (t - 0.5) * 2.0);
float rad = length((vUv - 0.5) * vec2(uAspect, 1.0));
ground = mix(ground, uVig, uVigA * smoothstep(0.34, 0.95, rad));
// Thin film. The edge detected normal decides WHERE the film sits, a couple
// of texels wide on the filament boundary, and the normal direction decides
// WHICH lip you see, so the two colours land on opposite faces of a fold.
vec3 nrm = texture2D(uNormal, vUv).xyz;
float bend = length(texture2D(uNormalDelta, vUv).xyz);
float film = smoothstep(0.12, 0.85, bend) * clamp(d * 5.0, 0.0, 1.0);
float side = clamp(dot(nrm.xy, vec2(0.7071, 0.7071)) * 1.4, -1.0, 1.0);
vec3 iri = (uIriA * max(side, 0.0) + uIriB * max(-side, 0.0)) * film * uIriAmt;
float dens = clamp(d, 0.0, 1.6);
vec3 fluid = mix(uTail, uBody, hue);
fluid = mix(fluid, uCore, smoothstep(0.42, 1.05, dens));
// Light ground, so the dye is mixed into it rather than added on top of it.
// Additive over a pale ground clips to white instantly, which is why this
// hero is light only: the two need different compositing, not two palettes.
vec3 col = mix(ground, fluid, clamp(dens * 1.05, 0.0, 1.0));
col *= 1.0 - 0.035 * clamp(dens * 6.0, 0.0, 1.0) * (1.0 - clamp(dens * 1.6, 0.0, 1.0));
col += iri;
col += (bayer4(gl_FragCoord.xy) - 0.5) / 255.0;
gl_FragColor = vec4(col, 1.0);
}`
// Renders only the headline rectangle, as premultiply free RGBA8 so it can be
// read back cheaply and pushed into the small veil canvas over the type.
const VEIL = `
precision highp float; precision highp sampler2D;
varying vec2 vUv;
uniform sampler2D uDye; uniform vec2 uRectMin, uRectSize;
uniform vec3 uTint; uniform float uGain, uMaxAlpha;
void main () {
vec2 uv = uRectMin + vec2(vUv.x, 1.0 - vUv.y) * uRectSize;
float d = texture2D(uDye, uv).x;
gl_FragColor = vec4(uTint, clamp(d * uGain, 0.0, 1.0) * uMaxAlpha);
}`
/* ------------------------------------------------------------- gl helpers */
type GL = WebGL2RenderingContext
type Prog = { p: WebGLProgram; u: Record<string, WebGLUniformLocation | null> }
type FBO = {
tex: WebGLTexture
fbo: WebGLFramebuffer
w: number
h: number
tx: number
ty: number
}
type Double = { read: FBO; write: FBO; swap: () => void }
const hexCache = new Map<string, [number, number, number]>()
function hex3(v: string): [number, number, number] {
const hit = hexCache.get(v)
if (hit) return hit
// Hex only, and it says so rather than failing dark. parseInt on anything
// else returns NaN, NaN >> 16 & 255 is 0, and the old code cached that as
// pure black: one 'rgb(...)' in a palette silently blanked the entire
// surface with nothing in the console. Mid grey is wrong too, but it is
// visibly wrong, next to a line naming the value that did it.
const h = v.replace('#', '')
if (!/^([0-9a-f]{3}|[0-9a-f]{6})$/i.test(h)) {
if (!hexCache.has(v)) {
console.warn(
`FluidSimulationHero: palette colours must be #rgb or #rrggbb. Received "${v}".`,
)
}
const grey: [number, number, number] = [0.5, 0.5, 0.5]
hexCache.set(v, grey)
return grey
}
const n = parseInt(h.length === 3 ? h.replace(/./g, (c) => c + c) : h, 16)
const out: [number, number, number] = [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]
hexCache.set(v, out)
return out
}
// Deterministic value stream, 0 to 1. The composition must be reproducible frame
// for frame so the preview card and the publishing screenshot always catch the
// same picture, which is why nothing here reaches for Math.random.
function rnd(n: number) {
const s = Math.sin(n * 127.1) * 43758.5453
return s - Math.floor(s)
}
function shader(gl: GL, type: number, src: string, defines = ''): WebGLShader | null {
const s = gl.createShader(type)
if (!s) return null
gl.shaderSource(s, defines + src)
gl.compileShader(s)
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
gl.deleteShader(s)
return null
}
return s
}
/* ----------------------------------------------------------- props table */
/**
* @typedef {object} FluidNavItem
* @property {string} label Link text shown in the top right nav.
* @property {string} href Link target.
*/
/**
* @typedef {object} FluidTypography
* @property {string} fontFamily Family for all UI text. Empty inherits the surrounding page font.
* @property {string} displayFontFamily Family for the oversized watermark only.
* @property {string} watermarkStyle Watermark slant, 'italic' or 'normal'. Families with no real italic get a browser faked one that leans the glyphs together, which watermarkTracking buys back.
* @property {string} watermarkTracking Letter spacing on the watermark. At 460px every 0.01em is another 4.6px between glyphs.
* @property {string} eyebrowSize Font size of the small label above the headline.
* @property {string} headlineSize Font size of the h1. Any CSS length, clamp() included.
* @property {number} headlineWeight Font weight of the h1.
* @property {string} headlineTracking Letter spacing of the h1.
* @property {number} headlineLeading Line height of the h1, unitless.
* @property {string} sublineSize Font size of the paragraph under the headline.
* @property {string} ctaSize Font size of the pill button label.
* @property {string} hintSize Font size of the stir hint in the bottom right corner.
* @property {string} wordmarkSize Font size of the top left wordmark.
* @property {string} navSize Font size of the nav links.
* @property {string} watermarkSize Font size of the watermark bleeding off the lower left corner.
*/
/**
* @typedef {object} FluidPalette
* @property {string[]} ground Three background wash stops, painted at 155 degrees in the display shader.
* @property {string} vignette Colour mixed into the corners of the wash.
* @property {number} vignetteAlpha Vignette strength, 0 to 1.
* @property {string} core Dye colour where density is highest.
* @property {string} body Dye colour at working density.
* @property {string} tail Dye colour in the thin trailing filaments.
* @property {string} iridescenceA Thin film lip on the leading side of a filament.
* @property {string} iridescenceB Thin film lip on the trailing side.
* @property {number} iridescence Strength of both lips, 0 to 1.
* @property {string} veil Colour the fluid pushes the headline toward as it passes over.
* @property {number} veilMax Veil alpha ceiling, 0 to switch the veil off entirely. If you turn it on, 0.4 keeps the headline above 4.5:1.
* @property {string} text Headline colour.
* @property {string} textSoft Nav, eyebrow and sub line colour.
* @property {string} watermark Colour of the oversized script watermark.
* @property {string} pillBg Call to action background.
* @property {string} pillFg Call to action label colour.
*/
/**
* @typedef {object} FluidIntro
* @property {number} duration Seconds the fluid takes to grow from one point into the resting mass. 0 opens on the settled composition instead.
* @property {number} burst Outward kick on the ignition frame, as a multiple of the running spawner velocity.
* @property {number} overshoot How far past the resting radius the ring swells mid bloom before easing back. 1 is no overshoot.
* @property {number} textDelay Seconds from mount before the first text element arrives.
* @property {number} textStagger Seconds between one arriving text element and the next.
*/
/**
* @typedef {object} FluidSimulationHeroProps
* @property {string[]} [headline=['Every pixel moves', 'like water']] Headline lines, rendered as a real h1.
* @property {string} [eyebrow=''] Small label above the headline. Empty by default, so nothing renders and the headline takes the top of the block; pass a string to bring it back.
* @property {string} [subline='A live Navier Stokes surface...'] Paragraph under the headline.
* @property {string} [hint='Drag anywhere to stir the surface'] Caption shown only to fine pointers with motion enabled.
* @property {string} [wordmark='Fluid Simulation Hero'] Top left wordmark.
* @property {string} [watermark='fluid'] Oversized script watermark bleeding off the lower left corner.
* @property {FluidNavItem[]} [navItems=NAV] Up to three nav links. Below 1024px all of them fold into a menu behind a button rather than any of them being dropped.
* @property {string} [wordmarkHref='#'] Target of the top left wordmark link.
* @property {string} [ctaLabel='Start a project'] Label of the pill button.
* @property {() => void} [onCtaClick] Called when the pill button is pressed.
* @property {FluidPalette} [palette=PALETTE] The full colour set. This hero is light only; there is no dark variant to fall back to.
* @property {FluidTypography} [typography=TYPO] Fonts and the full type scale. fontFamily empty inherits the page font; displayFontFamily styles the watermark alone, and watermarkStyle must drop to 'normal' for any family with no real italic.
* @property {FluidComposition} [composition=COMPOSITION] Where the mass sits and how large it is, one anchor per frame width. `wide` hangs it in the upper right with the type beside it; `narrow` stacks, mass on top and type under. The switch is the same 768px the text block uses, read live, so a rotation or a window drag re-aims the composition with no remount.
* @property {FluidIntro} [intro=INTRO] The arrival. The fluid is ignited on the first frame and grows into the resting mass while the type lands on the same clock. Set duration to 0 to open on the settled composition with no bloom. Reduced motion skips the whole thing and gets the settled still.
* @property {string} [chromeInsetTop='clamp(16px, 2.5vw, 32px)'] Distance from the top edge to the wordmark and nav.
* @property {string} [chromeInsetSide='clamp(32px, 3.8vw, 48px)'] Distance from the left and right edges. Larger than the top on purpose, see the note at the destructure.
* @property {number} [simResolution=192] Velocity and pressure grid on the short edge. Lower is faster and softer.
* @property {number} [dyeResolution=640] Dye grid on the short edge, clamped to the canvas. This is the detail you actually see.
* @property {number} [curl=8] Vorticity confinement strength. Zero kills the filaments. Against this much injection it boils well before 50; the old 15 already frothed the mass instead of folding it.
* @property {number} [densityDissipation=0.975] Per frame dye decay, and the number the rest of the set is balanced against. Near 1 keeps structure alive for many seconds and turns the surface still; raise it without pulling spawnAmplification, spawnVelocity and splatRadius down with it and the mass sprawls across the frame.
* @property {number} [velocityDissipation=0.952] Per frame velocity decay.
* @property {number} [pressureIterations=12] Jacobi iterations per step. Fewer is faster and looser.
* @property {number} [splatRadius=0.0028] Injection radius as a fraction of the short edge. Wide, because a single pointer stroke is one point here rather than a whole source texture, and a fine one disappears against this dye decay.
* @property {number} [splatForce=5400] Velocity injected per unit of pointer travel.
* @property {number} [intensity=1] Master multiplier on every splat and on the spawner, 0 to 1.
* @property {number} [normalEpsilon=0.005] Flat area guard in the surface normal pass. Larger softens the iridescent film, smaller sharpens and speckles it.
* @property {number} [iridescenceStrength=1] Master multiplier over the palette's iridescence value. Zero removes the thin film entirely.
* @property {number} [spawnAmplification=0.03] Dye the spawner injects per frame. This is what holds the resting mass together with no pointer on screen; raise it and the mass gets denser, not bigger.
* @property {number} [spawnVelocity=27] Velocity the spawner injects per frame, in grid texels per second. This sets how fast the mass turns over, and it is the difference between a bloom of colour and something that throws filaments. It stays contained this high only because the dye is spent quickly; see densityDissipation.
* @property {string} [className] Extra classes merged onto the outermost root element.
*/State
- every hook, handler, effect and disposal
- the animation loop, frame by frame
Tree
- the JSX, every className and inline style
Why · Remix · Check
- the mechanism, the tuning points, the checks
Unlock Fluid Simulation Hero
The full source and the remix prompt ship with Premium.