3D wave background
A wavy surface in perspective, drawn as lines or dots with WebGL. Glides toward the viewer, crests catch the accent, the pointer raises the waves under it.
A surface that glides toward you
A wave field draws a wavy surface in perspective, as lines or as dots, that glides toward the viewer. It gives a page motion and depth at the same time and leaves the upper half free for the copy.
Hero above the waves sets the copy above a field of lines that glides toward the viewer. Dotted band lays a low field of dots under a full width band of figures. Featured card fills the card with tall waves like a sound wave. Behind a call to action brings a calm sea of dots up to the middle of the closing panel.
Lines feel precise and technical, dots feel lighter, pick one style per page. The card version suits audio, voice and data products, where the wave shape tells the story by itself.
Variants
Details
What Wave field brings along, read from the registry and the source.
- Category
- 3D backgrounds
- Area
- Visuals
- Variants
- 4
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/wave-field.tsx
- Brings along
- utilskitwebgl
- npm packages
- None
- Languages
- Texts come through props
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The WaveFieldProps interface from components/ui/wave-field.tsx, with the comments from the code. Also takes every prop of ShaderLayerProps.
patternoptionalWaveFieldPatternlinesdots
lines draws each row as a line, dots as a grid of points
rowsoptionalnumberRows on desktop, 12 to 64, default 44. Each pixel only looks at the rows near its own depth, so more rows cost nothing extra.
horizonoptionalnumberHeight of the horizon as share of the box from the bottom, default 0.55
amplitudeoptionalnumberWave height, 1 is the designed height
Accessibility
Read from the source of the component.
- Needs no client state. The server writes it in full, so it reads without JavaScript too.
Motion
- No animations, it stands still from the start.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 128 lines
'use client';
import { cn } from '@/lib/kit';
import { ShaderBackdrop, ShaderCanvas, type ShaderLayerProps } from '@/components/ui/webgl';
/*
* A wavy surface seen in perspective, drawn as lines or dots. Rows sit at
* depths that grow geometrically toward the horizon, every row is lifted by
* the waves at its depth and projected onto the screen. The field glides
* toward the viewer, crests catch the accent, far rows fade into haze. The
* pointer raises the waves under it. Phones draw fewer rows.
*/
export type WaveFieldPattern = 'lines' | 'dots';
export interface WaveFieldProps extends ShaderLayerProps {
/** lines draws each row as a line, dots as a grid of points */
pattern?: WaveFieldPattern;
/** Rows on desktop, 12 to 64, default 44. Each pixel only looks at the rows near its own depth, so more rows cost nothing extra. */
rows?: number;
/** Height of the horizon as share of the box from the bottom, default 0.55 */
horizon?: number;
/** Wave height, 1 is the designed height */
amplitude?: number;
}
const FRAG = `
uniform float u_rows;uniform float u_horizon;uniform float u_amp;uniform float u_dots;
float wave(float x,float z,float t){
return (sin(x*1.15+t*.9+z*.55)*.55+sin(x*.53-t*.6+z*1.35)*.35+sin(x*2.1+z*.8-t*1.3)*.12)*u_amp;
}
void main(){
float m=min(u_res.x,u_res.y);
vec2 p=vec2(gl_FragCoord.x-.5*u_res.x,gl_FragCoord.y)/m;
float yh=u_res.y/m*u_horizon;
float px=1./m;
float f=yh*.55;
float t=u_time*.5;
float mx=(u_mouse.x-.5)*u_res.x/m;
float n=floor(u_rows*mix(.6,1.,u_quality));
float ph=fract(u_time*.08+u_scroll*.6);
vec3 col=vec3(0.);float al=0.;
float zf=f/max(yh-p.y,.002);
float ic=floor(log(max(zf/.45,1.))/log(30.)*n-1.+ph);
for(int j=0;j<15;j++){
float fi=ic-7.+float(j);if(fi<0.||fi>=n)continue;
float k=(fi+1.-ph)/n;
float z=.45*pow(30.,k);
float xw=p.x*z/f;
float bump=u_hover*.55*exp(-(p.x-mx)*(p.x-mx)*3.)*u_amp;
float h=wave(xw,z,t)*.32+bump*.3;
float y=yh-(1.-h)*f/z;
float dy=abs(p.y-y);
if(dy>.08)continue;
float far=k;
float w=px*mix(1.5,.75,far);
float v;
if(u_dots>.5){
float sp=.55;
float dx=(fract(xw/sp+.5)-.5)*sp*f/z;
float r=px*mix(2.6,.9,far);
v=smoothstep(r+px,r-px*.5,length(vec2(dx,p.y-y)));
}else{
v=smoothstep(w+px,w-px*.4,dy);
}
float fade=smoothstep(.88,.35,far)*smoothstep(0.,.08,k);
float crest=smoothstep(.0,.3,h);
vec3 c=mix(u_c3,u_c0,clamp(crest*.85+bump*.4+(1.-u_dark)*.35,0.,1.));
float a=v*fade*(.42+.58*crest);
col+=c*a;al+=a;
}
float glow=exp(-abs(p.y-yh)*7.)*.2*mix(.5,1.,u_dark)*smoothstep(yh+.18,yh-.02,p.y);
col+=u_c0*glow;al+=glow;
float kk=mix(.62,1.,u_dark)*u_intensity;
al=clamp(al*kk,0.,1.);
gl_FragColor=vec4(min(col*kk,vec3(1.)),al);
}`;
/* Still picture: a floor of lines tilted back with CSS, fading into the horizon */
function Still({ pattern, horizon }: { pattern: WaveFieldPattern; horizon: number }) {
const ink = 'color-mix(in oklab, var(--primary) 55%, var(--foreground))';
const bg =
pattern === 'dots'
? `radial-gradient(circle, ${ink} 0 1.5px, transparent 2.2px) 0 0 / 26px 26px`
: `repeating-linear-gradient(0deg, ${ink} 0 2px, transparent 2px 22px)`;
return (
<div className="absolute inset-x-0 bottom-0 overflow-hidden [perspective:420px]" style={{ height: `${Math.round(horizon * 100)}%` }}>
<div
className={cn('absolute inset-x-[-60%] bottom-[-10%] top-0 origin-bottom')}
style={{ background: bg, transform: 'rotateX(64deg)', maskImage: 'linear-gradient(to top, #000 10%, transparent 92%)', WebkitMaskImage: 'linear-gradient(to top, #000 10%, transparent 92%)', opacity: 0.9 }}
/>
<div className="absolute inset-x-0 top-0 h-px" style={{ boxShadow: '0 0 60px 14px color-mix(in oklab, var(--primary) 26%, transparent)' }} />
</div>
);
}
/**
* A gliding wave surface in perspective behind any content. With children it
* renders a box, without children only the layer for a `relative` parent.
*/
export function WaveField({ intensity = 1, speed = 1, colors, interactive = true, pattern = 'lines', rows = 44, horizon = 0.55, amplitude = 1, fallback, scrim = 'none', scrimStrength, scrimColor, children, className, contentClassName, layerClassName }: WaveFieldProps) {
const h = Math.min(0.95, Math.max(0.15, horizon));
return (
<ShaderBackdrop
scrim={scrim}
scrimStrength={scrimStrength}
scrimColor={scrimColor}
className={className}
contentClassName={contentClassName}
layer={
<ShaderCanvas
fragment={FRAG}
colors={colors ?? ['var(--primary)', 'var(--primary)', 'var(--primary)', 'color-mix(in oklab, var(--primary) 30%, var(--foreground))']}
uniforms={{ u_rows: Math.max(12, Math.min(64, Math.round(rows))), u_horizon: h, u_amp: amplitude, u_dots: pattern === 'dots' ? 1 : 0 }}
speed={speed}
intensity={intensity}
maxDpr={2}
phoneDpr={1.5}
pointer={interactive}
fallback={fallback ?? <Still pattern={pattern} horizon={h} />}
className={layerClassName}
/>
}
>
{children}
</ShaderBackdrop>
);
}Frequent questions
How do I install Wave field?
With aiw add @ai-website/wave-field. The command puts components/ui/wave-field.tsx into your project, the code is yours from then on. It brings along webgl. It needs no further npm packages.
Which variants does Wave field have?
4 variants, Hero above the waves, Dotted band, Featured card and Behind a call to action.
Can an AI assistant build with Wave field?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Wave field with aiw_components and install it with aiw_add @ai-website/wave-field.