Swell Surface
Points moved in circles rather than up and down, so crests peak and troughs flatten without either being shaped.
The scene in this preview is the file shown here. The surrounding demo shell only provides context and is not part of the copied code.
"use client";
import * as React from "react";
import * as THREE from "three";
/**
* Swell Surface — the crest sharpens because the water moves sideways.
*
* Displace a grid up and down with a sine and you get a corrugated
* sheet: every crest exactly as round as every trough, symmetric,
* obviously not water. Real swell is not symmetric. Crests are narrow
* and peaked, troughs are wide and flat, and the reason is that a
* particle in a wave does not bob straight up — it travels in a circle,
* moving forward at the top and backward at the bottom. That forward
* motion at the top bunches the surface into the crest.
*
* So this displaces along x and z as well as y, which is all a Gerstner
* wave is. Nothing sharpens the crest and nothing flattens the trough;
* both shapes are what happens when points move in circles rather than
* in lines. Steepness is the one parameter, and pushing it far enough
* would fold the surface over itself, exactly as a real wave breaks.
*
* Three waves at different headings share one clock, so the surface
* reads as one body of water rather than three sheets added together.
*
* Single file, react and three only. No textures, no add-ons.
*/
type Variant = "subtle" | "default" | "playful";
const VARIANTS = {
subtle: { swellRate: 0.05, crestCount: 3, lineOpacity: 0.3, amplitudeScale: 0.85 },
default: { swellRate: 0.12, crestCount: 5, lineOpacity: 0.5, amplitudeScale: 1.2 },
playful: { swellRate: 0.28, crestCount: 9, lineOpacity: 0.72, amplitudeScale: 1.75 },
} as const;
const PALETTES = {
night: {
deep: [0.03, 0.05, 0.08],
crest: [0.36, 0.78, 0.86],
trough: [0.1, 0.2, 0.4],
css: "linear-gradient(180deg, #0d1626 0%, #091220 55%, #060a12 100%)",
},
day: {
deep: [0.9, 0.93, 0.95],
crest: [0.08, 0.36, 0.5],
trough: [0.55, 0.68, 0.78],
css: "linear-gradient(180deg, #e6edf2 0%, #eff4f7 55%, #f5f8fa 100%)",
},
} as const;
const VERTEX = /* glsl */ `
uniform float uTime;
uniform float uCrests;
uniform float uAmplitude;
varying float vHeight;
varying float vFade;
/**
* One Gerstner wave. The horizontal term is the whole point: it is
* what turns a rounded sine into a peaked crest, because points near
* the top of the circle are carried forward into it.
*/
vec3 gerstner(vec2 xz, vec2 heading, float wavelength, float steepness, float t) {
float k = 6.2831853 / wavelength;
float c = sqrt(9.8 / k);
vec2 d = normalize(heading);
float f = k * (dot(d, xz) - c * t);
float a = steepness / k;
return vec3(d.x * a * cos(f), a * sin(f), d.y * a * cos(f));
}
void main() {
vec2 xz = position.xy;
float wavelength = 5.4 / max(uCrests, 1.0);
// One clock for all three, so this is a body of water with three
// swells crossing it rather than three surfaces stacked up.
vec3 offset = gerstner(xz, vec2(1.0, 0.35), wavelength * 2.6, 0.62 * uAmplitude, uTime)
+ gerstner(xz, vec2(-0.4, 1.0), wavelength * 1.5, 0.42 * uAmplitude, uTime)
+ gerstner(xz, vec2(0.8, -0.7), wavelength * 0.85, 0.24 * uAmplitude, uTime);
vec3 displaced = vec3(xz.x + offset.x, offset.y, xz.y + offset.z);
vHeight = offset.y;
vec4 viewPosition = modelViewMatrix * vec4(displaced, 1.0);
// Distance haze. Without it the far edge of the plane is a straight
// line across the frame, which announces that the sea is a rectangle
// — the one thing a body of water must not look like.
vFade = 1.0 - smoothstep(7.0, 15.5, -viewPosition.z);
gl_Position = projectionMatrix * viewPosition;
}
`;
const FRAGMENT = /* glsl */ `
uniform float uOpacity;
uniform vec3 uCrest;
uniform vec3 uTrough;
varying float vHeight;
varying float vFade;
void main() {
// Colour from height, so the crests are legible on a wireframe
// where there is no shading to read.
float lift = clamp(vHeight * 1.7 + 0.5, 0.0, 1.0);
vec3 color = mix(uTrough, uCrest, lift * lift);
gl_FragColor = vec4(color, uOpacity * (0.35 + 0.65 * lift) * vFade);
}
`;
/** Relative luminance of a computed rgb() colour, 0–1. */
function luminanceOf(color: string): number {
const parts = color.match(/[\d.]+/g);
if (!parts || parts.length < 3) return 0.9;
const [r, g, b] = parts.slice(0, 3).map((value) => Number(value) / 255);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
export type SwellSurfaceProps = {
variant?: Variant;
height?: string;
className?: string;
style?: React.CSSProperties;
children?: React.ReactNode;
};
export function SwellSurface({
variant = "default",
height = "100%",
className,
style,
children,
}: SwellSurfaceProps) {
const hostRef = React.useRef<HTMLDivElement | null>(null);
const cfg = VARIANTS[variant] ?? VARIANTS.default;
React.useEffect(() => {
const host = hostRef.current;
if (!host) return;
const palette =
luminanceOf(getComputedStyle(host).color) > 0.5 ? PALETTES.night : PALETTES.day;
host.style.background = palette.css;
const reduced =
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let renderer: THREE.WebGLRenderer;
try {
renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: false,
powerPreference: "low-power",
});
} catch {
return; // No WebGL. The CSS still above is already painted.
}
const canvas = renderer.domElement;
canvas.style.cssText =
"position:absolute;inset:0;width:100%;height:100%;display:block;pointer-events:none;";
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setClearColor(
new THREE.Color(palette.deep[0], palette.deep[1], palette.deep[2]),
1
);
host.insertBefore(canvas, host.firstChild);
const uniforms = {
uTime: { value: 0 },
uCrests: { value: 5 },
uAmplitude: { value: 1.2 },
uOpacity: { value: 0.5 },
uCrest: { value: new THREE.Vector3(...palette.crest) },
uTrough: { value: new THREE.Vector3(...palette.trough) },
};
uniforms.uCrests.value = cfg.crestCount;
uniforms.uAmplitude.value = cfg.amplitudeScale;
uniforms.uOpacity.value = cfg.lineOpacity;
// 72 × 72 is where the crest still reads as a line rather than as a
// staircase, without asking a phone for four times the vertices.
const geometry = new THREE.PlaneGeometry(16, 16, 72, 72);
const material = new THREE.ShaderMaterial({
vertexShader: VERTEX,
fragmentShader: FRAGMENT,
uniforms,
wireframe: true,
transparent: true,
depthWrite: false,
});
const mesh = new THREE.Mesh(geometry, material);
const scene = new THREE.Scene();
scene.add(mesh);
const camera = new THREE.PerspectiveCamera(38, 1, 0.1, 60);
camera.position.set(0, 1.15, 5.6);
camera.lookAt(0, -0.25, 0);
const resize = () => {
const width = Math.max(1, host.clientWidth);
const heightPx = Math.max(1, host.clientHeight);
renderer.setSize(width, heightPx, false);
camera.aspect = width / heightPx;
camera.updateProjectionMatrix();
};
resize();
const observer = new ResizeObserver(resize);
observer.observe(host);
let frame = 0;
let phase = 0;
let last = 0;
let running = false;
const draw = () => renderer.render(scene, camera);
const tick = (now: number) => {
const delta = last ? Math.min((now - last) / 1000, 0.05) : 0;
last = now;
phase += delta * cfg.swellRate * 6.0;
uniforms.uTime.value = phase;
draw();
frame = requestAnimationFrame(tick);
};
const start = () => {
if (running) return;
running = true;
last = 0;
frame = requestAnimationFrame(tick);
};
const stop = () => {
running = false;
cancelAnimationFrame(frame);
};
const onLost = (event: Event) => {
event.preventDefault();
stop();
};
const onRestored = () => {
resize();
start();
};
canvas.addEventListener("webglcontextlost", onLost);
canvas.addEventListener("webglcontextrestored", onRestored);
if (reduced) {
// A swell held still is still a swell: the peaked crests and flat
// troughs are the shape of the surface, not of the animation.
uniforms.uTime.value = 2.7;
draw();
} else {
start();
}
return () => {
stop();
observer.disconnect();
canvas.removeEventListener("webglcontextlost", onLost);
canvas.removeEventListener("webglcontextrestored", onRestored);
geometry.dispose();
material.dispose();
renderer.dispose();
renderer.forceContextLoss();
canvas.remove();
};
}, [cfg]);
return (
<div
ref={hostRef}
className={className}
style={{
position: "relative",
width: "100%",
height,
overflow: "hidden",
background: PALETTES.night.css,
...style,
}}
>
{children}
</div>
);
}
export default SwellSurface;About this effect
For something that is working and does not need watching — a sync, a background job, a long import — where a rhythm is wanted but a progress bar's implied promise about when it will finish is not. Displace a grid up and down with a sine and the result is a corrugated sheet: every crest as round as every trough, symmetric, obviously not water. Real swell is not symmetric, and the reason is that a particle in a wave does not bob straight up. It travels in a circle, moving forward at the top and backward at the bottom, and that forward motion at the top bunches the surface into a narrow, peaked crest while the trough spreads out flat. So this displaces along x and z as well as y, which is all a Gerstner wave is. Nothing sharpens a crest and nothing flattens a trough; both shapes are what happens when points move in circles instead of in lines, and pushing the steepness far enough would fold the surface over itself exactly as a real wave breaks. Three swells at different headings share one clock, so it reads as one body of water crossed by three waves rather than as three sheets added together.
Related effects
- Star ParallaxSize, brightness and colour all read off one depth, so the field reads as distance rather than as assorted dots.
- Aurora CurtainRays that arrive where a sampled sheet folds end-on, so no line of code decides where a ray belongs.
- Fog BankSlabs composited back to front, each dimming what is behind it, so the bank has an inside rather than a surface.