Levend verloop: drie lagen simplex-ruis mengen vier kleuren over een bodemkleur, met een vignet naar de randen, een gloed in het midden en fijne filmkorrel. WebGL, nul dependencies; kleuren zijn CSS-kleuren inclusief tokens (default --muted/--primary/--info/--background). Stopt buiten beeld en onder reduced motion.
WebGL
Een verloop dat leeft
Simplex-ruis mengt --muted, --primary, --info en --background; vignet, gloed en korrel erbovenop. Wissel van skin en herlaad de demo: het palet volgt de tokens.
primitives/backgrounds/noise-gradient.tsx
/**
* NoiseGradient — een levend verloop: drie lagen simplex-ruis mengen vier
* kleuren over een bodemkleur, met een vignet naar de randen, een gloed in
* het midden en fijne filmkorrel. WebGL, nul dependencies. Kinderen renderen
* bovenop het vlak.
*
* Geoogst uit 21st.dev Velaris (amanshakya307): vaste hexkleuren
* (#86efac, #4ade80, #059669, #000) → `colors`/`bg` als willekeurige CSS-
* kleuren INCLUSIEF var(--token) (default: --background, --muted, --primary,
* --info, --background), geparsed via een 2D-canvas zoals shader-background;
* shaders ongewijzigd; render-lus stopt buiten beeld en onder reduced motion
* (dan één stilstaand frame); `height` → CSS-lengte-prop.
*/
"use client";
import { useEffect, useRef } from "react";
import type * as React from "react";
export interface NoiseGradientProps extends React.HTMLAttributes<HTMLDivElement> {
/** Bodemkleur; elke CSS-kleur incl. var(--token). Default var(--background). */
bg?: string;
/** Vier mengkleuren; elke CSS-kleur incl. var(--token). */
colors?: readonly string[];
/** Tempo van de ruis. Default 2. */
speed?: number;
/** Filmkorrel 0–1. Default 0.3. */
grain?: number;
/** Hoogte als CSS-lengte. Default 100vh. */
height?: string;
}
const VERTEX = `attribute vec2 position; varying vec2 vUv; void main(){ vUv = position * 0.5 + 0.5; gl_Position = vec4(position, 0.0, 1.0); }`;
const FRAGMENT = `precision highp float; varying vec2 vUv; uniform vec2 u_resolution; uniform float u_time; uniform float u_grain; uniform vec3 u_colors[4]; uniform vec3 u_bg;
vec3 permute(vec3 x){ return mod(((x*34.0)+1.0)*x, 289.0); }
float snoise(vec2 v){ const vec4 C = vec4(0.211324865405187, 0.366025403784439, -0.577350269189626, 0.024390243902439); vec2 i = floor(v + dot(v, C.yy)); vec2 x0 = v - i + dot(i, C.xx); vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0); vec4 x12 = x0.xyxy + C.xxzz; x12.xy -= i1; i = mod(i, 289.0); vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0)); vec3 m = max(0.5 - vec3(dot(x0,x0), dot(x12.xy,x12.xy), dot(x12.zw,x12.zw)), 0.0); m = m*m; m = m*m; vec3 x = 2.0 * fract(p * C.www) - 1.0; vec3 h = abs(x) - 0.5; vec3 ox = floor(x + 0.5); vec3 a0 = x - ox; m *= 1.79284291400159 - 0.85373472095314 * (a0*a0 + h*h); vec3 g; g.x = a0.x * x0.x + h.x * x0.y; g.yz = a0.yz * x12.xz + h.yz * x12.yw; return 130.0 * dot(m, g); }
void main(){ vec2 uv = vUv; float ratio = u_resolution.x / u_resolution.y; vec2 p = uv - 0.5; p.x *= ratio; float t = u_time * 0.1;
float n1 = snoise(p * 0.4 + vec2(t * 0.2, -t * 0.3)); float n2 = snoise(p * 0.55 + vec2(-t * 0.15, t * 0.25) + n1 * 0.25); float n3 = snoise(p * 0.75 + vec2(t * 0.1, -t * 0.2) + n2 * 0.2);
vec3 col = u_bg; float dist = length(p) * 1.5; float vignette = 1.0 - smoothstep(0.3, 1.2, dist);
col = mix(col, u_colors[0], smoothstep(-0.2, 0.5, n1) * 0.85); col = mix(col, u_colors[1], smoothstep(-0.1, 0.6, n2) * 0.7); col = mix(col, u_colors[2], smoothstep(-0.3, 0.4, n3) * 0.6); col = mix(col, u_colors[3], smoothstep(0.0, 0.7, n1 * n2) * 0.5);
float glow = smoothstep(0.8, 0.0, dist) * 0.3; col += u_colors[1] * glow; col = mix(col * 0.2, col, vignette);
float grain = fract(sin(dot(uv, vec2(12.9898, 78.233))) * 43758.5453 + u_time); col += (grain - 0.5) * u_grain * 0.1; gl_FragColor = vec4(col, 1.0); }`;
const STANDAARD = ["var(--muted)", "var(--primary)", "var(--info)", "var(--background)"];
/** Elke CSS-kleur naar rgb 0–1, met het element als context voor var(--token); een 2D-canvas parset. */
function naarRgb(kleur: string, context: Element): [number, number, number] {
const doek = document.createElement("canvas");
doek.width = doek.height = 1;
const ctx = doek.getContext("2d", { willReadFrequently: true });
if (!ctx) return [0, 0, 0];
let css = kleur.trim();
const m = css.match(/^var\(\s*(--[a-zA-Z0-9-]+)\s*\)$/);
if (m) css = getComputedStyle(context).getPropertyValue(m[1]!).trim();
ctx.fillStyle = css || "black";
ctx.fillRect(0, 0, 1, 1);
const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
return [r! / 255, g! / 255, b! / 255];
}
export function NoiseGradient({ bg = "var(--background)", colors = STANDAARD, speed = 2, grain = 0.3, height = "100vh", className, style, children, ...rest }: NoiseGradientProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const kleurSleutel = colors.join("|");
useEffect(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
const gl = canvas.getContext("webgl");
if (!gl) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)");
const shader = (type: number, src: string) => { const s = gl.createShader(type)!; gl.shaderSource(s, src); gl.compileShader(s); return s; };
const program = gl.createProgram()!;
gl.attachShader(program, shader(gl.VERTEX_SHADER, VERTEX));
gl.attachShader(program, shader(gl.FRAGMENT_SHADER, FRAGMENT));
gl.linkProgram(program);
gl.useProgram(program);
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
const pos = gl.getAttribLocation(program, "position");
gl.enableVertexAttribArray(pos);
gl.vertexAttribPointer(pos, 2, gl.FLOAT, false, 0, 0);
const locs = { res: gl.getUniformLocation(program, "u_resolution"), time: gl.getUniformLocation(program, "u_time"), grain: gl.getUniformLocation(program, "u_grain"), colors: gl.getUniformLocation(program, "u_colors"), bg: gl.getUniformLocation(program, "u_bg") };
// Kleuren één keer oplossen (tokens van het project → rgb) en als uniform zetten.
const vier = [...colors.slice(0, 4)];
while (vier.length < 4) vier.push(vier[vier.length - 1] ?? "var(--background)");
const flat = new Float32Array(vier.flatMap((k) => naarRgb(k, container)));
const bodem = naarRgb(bg, container);
gl.uniform3fv(locs.colors, flat);
gl.uniform3f(locs.bg, bodem[0], bodem[1], bodem[2]);
gl.uniform1f(locs.grain, grain);
const resize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.max(1, container.clientWidth * dpr);
canvas.height = Math.max(1, container.clientHeight * dpr);
gl.viewport(0, 0, canvas.width, canvas.height);
};
const ro = new ResizeObserver(resize);
ro.observe(container);
let raf = 0, zichtbaar = true;
const frame = (t: number) => {
gl.uniform2f(locs.res, canvas.width, canvas.height);
gl.uniform1f(locs.time, t * 0.001 * speed);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
};
const render = (t: number) => {
raf = 0;
frame(t);
if (zichtbaar && !reduce.matches && !document.hidden) raf = requestAnimationFrame(render);
};
const wek = () => { if (!raf) raf = requestAnimationFrame(render); };
const io = new IntersectionObserver(([e]) => { zichtbaar = e?.isIntersecting ?? true; if (zichtbaar) wek(); }, { threshold: 0 });
io.observe(container);
document.addEventListener("visibilitychange", wek);
reduce.addEventListener("change", wek);
resize();
wek();
return () => {
ro.disconnect(); io.disconnect();
document.removeEventListener("visibilitychange", wek);
reduce.removeEventListener("change", wek);
cancelAnimationFrame(raf);
};
}, [bg, kleurSleutel, speed, grain]); // eslint-disable-line react-hooks/exhaustive-deps -- colors via kleurSleutel
return (
<div ref={containerRef} {...rest} className={["fp-noise", className].filter(Boolean).join(" ")} style={{ ...style, height }}>
<canvas ref={canvasRef} className="fp-noise__doek" aria-hidden="true" />
<div className="fp-noise__inhoud">{children}</div>
</div>
);
}
primitives/backgrounds/backgrounds.css
/**
* Achtergronden II — importeren in de app-root (registry-site: app/globals.css).
* Token-contract 2. Canvas-vlakken die inhoud dragen: het vlak zelf is het
* raster, de kinderen liggen erbovenop.
*/
/* ── sonar-grid ─────────────────────────────────────────────────────────── */
.fp-sonar { position: relative; isolation: isolate; overflow: hidden; width: 100%; }
.fp-sonar--interactief { cursor: crosshair; }
/* De stippenkleur is currentColor van het doek: standaard de merkkleur, via `color` te overschrijven. */
.fp-sonar__doek { position: absolute; inset: 0; z-index: -1; width: 100%; height: 100%; color: var(--primary); pointer-events: none; }
/* ── noise-gradient ─────────────────────────────────────────────────────── */
.fp-noise { position: relative; width: 100%; overflow: hidden; }
.fp-noise__doek { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
.fp-noise__inhoud { position: relative; z-index: 1; width: 100%; height: 100%; }