Contour Field
Domain-warped fBm contour lines in a duotone — F1Peak's signature generative background, re-implemented as a dependency-free raw-WebGL2 component with live, adjustable props (colours, scale, speed, warp, line count, thickness). Respects reduced-motion.
"use client";
import * as React from "react";
export interface ContourFieldProps {
/** primary duotone colour (hex) */
colorA?: string;
/** secondary duotone colour (hex) */
colorB?: string;
/** field zoom — bigger = finer detail */
scale?: number;
/** animation speed */
speed?: number;
/** domain-warp strength */
warp?: number;
/** number of contour bands */
lines?: number;
/** line thickness (0..1) */
thickness?: number;
/** overall opacity */
intensity?: number;
className?: string;
style?: React.CSSProperties;
}
const FRAG = `#version 300 es
precision highp float;
out vec4 frag;
uniform vec2 uRes; uniform float uTime;
uniform vec3 uColA; uniform vec3 uColB;
uniform float uScale, uSpeed, uWarp, uLines, uThick, uIntensity;
float hash(vec2 p){ p=fract(p*vec2(123.34,345.45)); p+=dot(p,p+34.345); return fract(p.x*p.y); }
float noise(vec2 p){ vec2 i=floor(p),f=fract(p);
float a=hash(i),b=hash(i+vec2(1,0)),c=hash(i+vec2(0,1)),d=hash(i+vec2(1,1));
vec2 u=f*f*(3.-2.*f); return mix(mix(a,b,u.x),mix(c,d,u.x),u.y); }
float fbm(vec2 p){ float v=0.,a=0.5; for(int i=0;i<6;i++){ v+=a*noise(p); p=p*2.0+vec2(1.7,9.2); a*=0.5; } return v; }
void main(){
vec2 uv=(gl_FragCoord.xy-0.5*uRes)/uRes.y;
float t=uTime*uSpeed;
vec2 p=uv*uScale;
vec2 q=vec2(fbm(p+vec2(0.0,0.3*t)), fbm(p+vec2(5.2,1.3)-vec2(0.0,0.22*t)));
float f=fbm(p+uWarp*q+0.1*t);
float bands=f*uLines;
float edge=abs(fract(bands)-0.5)*2.0;
float line=smoothstep(uThick+0.05, uThick, edge);
float glow=smoothstep(uThick+0.35, uThick, edge)*0.25;
vec3 col=mix(uColA,uColB,smoothstep(0.2,0.8,f));
float a=(line+glow)*uIntensity;
frag=vec4(col, clamp(a,0.0,1.0));
}`;
const VERT = `#version 300 es
void main(){ vec2 v[3]=vec2[3](vec2(-1,-1),vec2(3,-1),vec2(-1,3)); gl_Position=vec4(v[gl_VertexID],0,1); }`;
function hexToRgb(hex: string): [number, number, number] {
const h = hex.replace("#", "");
const n = h.length === 3 ? h.split("").map((c) => c + c).join("") : h;
return [parseInt(n.slice(0, 2), 16) / 255, parseInt(n.slice(2, 4), 16) / 255, parseInt(n.slice(4, 6), 16) / 255];
}
export function ContourField({
colorA = "#c2f53b",
colorB = "#ff2e43",
scale = 2.4,
speed = 0.5,
warp = 1.4,
lines = 7,
thickness = 0.06,
intensity = 1,
className,
style,
}: ContourFieldProps) {
const ref = React.useRef<HTMLCanvasElement | null>(null);
const props = React.useRef({ colorA, colorB, scale, speed, warp, lines, thickness, intensity });
props.current = { colorA, colorB, scale, speed, warp, lines, thickness, intensity };
React.useEffect(() => {
const canvas = ref.current;
if (!canvas) return;
const gl = canvas.getContext("webgl2", { alpha: true, antialias: true, premultipliedAlpha: false });
if (!gl) return;
const compile = (type: number, src: string) => {
const s = gl.createShader(type)!;
gl.shaderSource(s, src);
gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s) || "shader error");
return s;
};
const prog = gl.createProgram()!;
gl.attachShader(prog, compile(gl.VERTEX_SHADER, VERT));
gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, FRAG));
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog) || "link error");
gl.useProgram(prog);
const U = (n: string) => gl.getUniformLocation(prog, n);
const uRes = U("uRes"), uTime = U("uTime"), uColA = U("uColA"), uColB = U("uColB");
const uScale = U("uScale"), uSpeed = U("uSpeed"), uWarp = U("uWarp"), uLines = U("uLines"), uThick = U("uThick"), uInt = U("uIntensity");
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
const resize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const w = Math.max(1, Math.floor(canvas.clientWidth * dpr));
const h = Math.max(1, Math.floor(canvas.clientHeight * dpr));
if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; }
gl.viewport(0, 0, canvas.width, canvas.height);
};
const ro = new ResizeObserver(resize);
ro.observe(canvas);
resize();
let raf = 0;
const start = performance.now();
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const draw = (now: number) => {
const p = props.current;
resize();
gl.uniform2f(uRes, canvas.width, canvas.height);
gl.uniform1f(uTime, reduce ? 0 : (now - start) / 1000);
gl.uniform3fv(uColA, hexToRgb(p.colorA));
gl.uniform3fv(uColB, hexToRgb(p.colorB));
gl.uniform1f(uScale, p.scale);
gl.uniform1f(uSpeed, p.speed);
gl.uniform1f(uWarp, p.warp);
gl.uniform1f(uLines, p.lines);
gl.uniform1f(uThick, p.thickness);
gl.uniform1f(uInt, p.intensity);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArrays(gl.TRIANGLES, 0, 3);
raf = reduce ? 0 : requestAnimationFrame(draw);
};
raf = requestAnimationFrame(draw);
return () => { cancelAnimationFrame(raf); ro.disconnect(); gl.getExtension("WEBGL_lose_context")?.loseContext(); };
}, []);
return <canvas ref={ref} className={className} style={{ display: "block", width: "100%", height: "100%", ...style }} />;
}