The Kit/Backgrounds

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.

loading…
ContourField.tsx
"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 }} />;
}
← The whole kit