neon-ui
componentdot-matrix-wavesource: ok · hit
DotMatrixWave
Field of dots breathing in a drifting noise wave, colored by currentColor. The RegionSelect dot-map aesthetic as a background surface.
npx shadcn add "https://ui.neon.com/r/dot-matrix-wave.json"free
Visual style
particle
Interaction
dropdown
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightRegistry dependencies
utils
Files2 · source cached 10/9/2026, 12:06:06 AM
src/components/dot-matrix-wave/dot-matrix-wave.tsx
"use client";
/*
* DotMatrixWave: a field of dots breathing in a smooth noise wave,
* the RegionSelect dot-map aesthetic, generalized into a background
* surface. Color comes from currentColor at mount (same contract as
* NeonLoader and AnimatedWash), so a text-primary ancestor or the
* theme drives it with zero props; pass a colors array for a
* left-to-right gradient fade instead, like the neon.com green-to-
* blue type treatment. Static single frame under reduced motion.
* The GLSL lives in dot-matrix-wave-shader.ts.
*/
import { useEffect, useMemo, useRef } from "react";
import type { ComponentProps } from "react";
import { cn } from "@/lib/utils";
import { DOT_FRAGMENT, DOT_VERTEX } from "./dot-matrix-wave-shader";
export type DotMatrixWaveProps = Omit<ComponentProps<"canvas">, "children"> & {
/** Wave drift speed multiplier; 0 freezes the field. */
speed?: number;
/** Dot pitch in CSS pixels. */
gap?: number;
/** 0-1 dot radius as a share of the pitch. */
dotSize?: number;
/** 0-1 how hard the wave swells the dots. */
amplitude?: number;
/** 0-1 minimum brightness of resting dots. */
floor?: number;
/**
* Gradient stops spread evenly left to right, up to 6. Omit to
* paint the whole field with currentColor.
*/
colors?: string[];
};
const MAX_STOPS = 6;
/**
* Seconds for a dial change to close ~63% of its gap — uniforms ease
* toward their targets each frame, so a dragged slider glides
* instead of snapping.
*/
const SMOOTH_TAU = 0.12;
const parseColor = (css: string): [number, number, number] => {
const probe = document.createElement("canvas");
probe.width = 1;
probe.height = 1;
const context = probe.getContext("2d");
if (!context) {
return [0, 0.9, 0.6];
}
context.fillStyle = css;
context.fillRect(0, 0, 1, 1);
const [r, g, b] = context.getImageData(0, 0, 1, 1).data;
return [(r ?? 0) / 255, (g ?? 0) / 255, (b ?? 0) / 255];
};
const warnDev = (message: string) => {
if (typeof process !== "undefined" && process.env.NODE_ENV !== "production") {
console.warn(message);
}
};
const compile = (
gl: WebGLRenderingContext,
type: number,
source: string
): WebGLShader | null => {
const shader = gl.createShader(type);
if (!shader) {
return null;
}
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
warnDev(
`neon-ui: shader compile failed: ${gl.getShaderInfoLog(shader) ?? "unknown"}`
);
gl.deleteShader(shader);
return null;
}
return shader;
};
export const DotMatrixWave = ({
amplitude = 0.8,
className,
colors,
dotSize = 0.35,
floor = 0.08,
gap = 14,
speed = 1,
...props
}: DotMatrixWaveProps) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
// Shader time, accumulated frame by frame so a speed change scales
// the flow from here instead of teleporting the whole field.
const phaseRef = useRef(0);
const lastFrameRef = useRef<number | null>(null);
// Smoothed dial values, persisting across prop-driven re-inits.
const smoothedRef = useRef<Record<string, number> | null>(null);
const stopsKey = useMemo(() => colors?.join("|") ?? "", [colors]);
useEffect(() => {
const canvas = canvasRef.current;
const gl = canvas?.getContext("webgl", {
alpha: true,
premultipliedAlpha: true,
});
if (!(canvas && gl)) {
return;
}
const vertex = compile(gl, gl.VERTEX_SHADER, DOT_VERTEX);
const fragment = compile(gl, gl.FRAGMENT_SHADER, DOT_FRAGMENT);
const program = gl.createProgram();
if (!(vertex && fragment && program)) {
if (vertex) {
gl.deleteShader(vertex);
}
if (fragment) {
gl.deleteShader(fragment);
}
return;
}
gl.attachShader(program, vertex);
gl.attachShader(program, fragment);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
warnDev(
`neon-ui: program link failed: ${gl.getProgramInfoLog(program) ?? "unknown"}`
);
gl.deleteProgram(program);
gl.deleteShader(vertex);
gl.deleteShader(fragment);
return;
}
// oxlint-disable-next-line react/react-compiler -- WebGL method, not a React hook
gl.useProgram(program);
const quad = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, quad);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 3, -1, -1, 3]),
gl.STATIC_DRAW
);
const position = gl.getAttribLocation(program, "a_position");
gl.enableVertexAttribArray(position);
gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
const uResolution = gl.getUniformLocation(program, "u_resolution");
const uTime = gl.getUniformLocation(program, "u_time");
const uGap = gl.getUniformLocation(program, "u_gap");
const uDot = gl.getUniformLocation(program, "u_dot");
const uAmplitude = gl.getUniformLocation(program, "u_amplitude");
const uFloor = gl.getUniformLocation(program, "u_floor");
const dpr = Math.min(window.devicePixelRatio || 1, 2);
// The gradient: explicit stops, or currentColor as a single stop.
const stops =
colors && colors.length > 0
? colors.slice(0, MAX_STOPS).map(parseColor)
: [parseColor(getComputedStyle(canvas).color)];
const stopData = new Float32Array(MAX_STOPS * 3);
for (const [index, [r, g, b]] of stops.entries()) {
stopData[index * 3] = r;
stopData[index * 3 + 1] = g;
stopData[index * 3 + 2] = b;
}
gl.uniform1i(gl.getUniformLocation(program, "u_stop_count"), stops.length);
gl.uniform3fv(gl.getUniformLocation(program, "u_stops"), stopData);
const targets: Record<string, number> = {
amplitude,
dotSize,
floor,
gap,
speed,
};
smoothedRef.current ??= { ...targets };
const smoothed = smoothedRef.current;
/** Ease every dial toward its target and upload; k=1 snaps. */
const applyUniforms = (k: number) => {
for (const key of Object.keys(targets)) {
const current = smoothed[key] ?? targets[key] ?? 0;
smoothed[key] = current + ((targets[key] ?? 0) - current) * k;
}
gl.uniform1f(uGap, (smoothed.gap ?? gap) * dpr);
gl.uniform1f(uDot, smoothed.dotSize ?? dotSize);
gl.uniform1f(uAmplitude, smoothed.amplitude ?? amplitude);
gl.uniform1f(uFloor, smoothed.floor ?? floor);
};
let frame = 0;
let staticFrame = false;
const renderStatic = () => {
applyUniforms(1);
gl.uniform1f(uTime, 3);
gl.drawArrays(gl.TRIANGLES, 0, 3);
};
const resize = () => {
const width = Math.max(1, Math.round(canvas.clientWidth * dpr));
const height = Math.max(1, Math.round(canvas.clientHeight * dpr));
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
gl.viewport(0, 0, width, height);
}
gl.uniform2f(uResolution, canvas.width, canvas.height);
};
const observer = new ResizeObserver(() => {
resize();
if (staticFrame) {
renderStatic();
}
});
observer.observe(canvas);
resize();
const dispose = () => {
observer.disconnect();
gl.deleteBuffer(quad);
gl.deleteProgram(program);
gl.deleteShader(vertex);
gl.deleteShader(fragment);
};
const draw = (now: number) => {
const last = lastFrameRef.current ?? now;
lastFrameRef.current = now;
// Skip GL work while hidden: keep the loop alive, drop the cost.
if (!(canvas.checkVisibility?.() ?? true)) {
frame = requestAnimationFrame(draw);
return;
}
const dt = (now - last) / 1000;
applyUniforms(1 - Math.exp(-dt / SMOOTH_TAU));
phaseRef.current += dt * (smoothed.speed ?? speed);
gl.uniform1f(uTime, phaseRef.current);
gl.drawArrays(gl.TRIANGLES, 0, 3);
frame = requestAnimationFrame(draw);
};
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)");
if (reduced.matches || speed === 0) {
staticFrame = true;
resize();
renderStatic();
return dispose;
}
frame = requestAnimationFrame(draw);
return () => {
cancelAnimationFrame(frame);
dispose();
};
// stopsKey covers the colors array contents.
// oxlint-disable-next-line react-hooks/exhaustive-deps
}, [amplitude, dotSize, floor, gap, speed, stopsKey]);
return (
<canvas
aria-hidden="true"
className={cn("size-full", className)}
data-slot="dot-matrix-wave"
ref={canvasRef}
{...props}
/>
);
};
src/components/dot-matrix-wave/dot-matrix-wave-shader.ts
/*
* The visual shader for DotMatrixWave, kept separate from the React
* mount.
*
* A field of dots breathing in a smooth noise wave: the dot-matrix
* world map from RegionSelect, generalized into a background surface.
* Each dot's size and brightness ride a drifting value-noise field, so
* broad swells roll across the grid instead of dots blinking in
* unison.
*
* Fragment shader uniforms:
* - u_resolution (vec2): canvas resolution in pixels
* - u_time (float): animation time in seconds (pre-multiplied by speed)
* - u_gap (float): dot pitch in device pixels
* - u_dot (float): 0-1 dot radius as a share of the pitch
* - u_amplitude (float): 0-1 how hard the wave swells the dots
* - u_floor (float): 0-1 minimum brightness of resting dots
* - u_stop_count (int): number of live gradient stops (up to 6)
* - u_stops[6] (vec3): gradient stops, spread evenly left to right;
* a single stop paints the whole field flat (currentColor default)
*/
export const DOT_VERTEX = `
attribute vec2 a_position;
void main() {
gl_Position = vec4(a_position, 0.0, 1.0);
}
`;
export const DOT_FRAGMENT = `
precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;
uniform float u_gap;
uniform float u_dot;
uniform float u_amplitude;
uniform float u_floor;
uniform int u_stop_count;
uniform vec3 u_stops[6];
float hash(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}
float vnoise(vec2 p) {
vec2 i = floor(p);
vec2 f = smoothstep(0.0, 1.0, fract(p));
float a = hash(i);
float b = hash(i + vec2(1.0, 0.0));
float c = hash(i + vec2(0.0, 1.0));
float d = hash(i + vec2(1.0, 1.0));
return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
}
// Piecewise-linear gradient across evenly spread stops.
vec3 gradientAt(float t) {
vec3 color = u_stops[0];
float span = max(float(u_stop_count - 1), 1.0);
for (int i = 1; i < 6; i++) {
if (i >= u_stop_count) {
break;
}
float a = float(i - 1) / span;
float b = float(i) / span;
color = mix(color, u_stops[i], smoothstep(a, b, t));
}
return color;
}
void main() {
vec2 cell = floor(gl_FragCoord.xy / u_gap);
vec2 local = fract(gl_FragCoord.xy / u_gap) - 0.5;
// Two octaves of drifting noise: a broad swell plus finer chop, each
// sliding in its own direction so the field never loops visibly.
float swell = vnoise(cell * 0.08 + vec2(u_time * 0.25, u_time * 0.1));
float chop = vnoise(cell * 0.3 - vec2(u_time * 0.15, u_time * 0.3));
float wave = swell * 0.7 + chop * 0.3;
// The wave swells both radius and brightness; resting dots keep a
// faint floor so the grid never fully disappears.
float energy = u_floor + (1.0 - u_floor) * pow(wave, 1.6) * u_amplitude +
(1.0 - u_amplitude) * (1.0 - u_floor) * 0.25;
float radius = u_dot * 0.5 * (0.55 + 0.45 * wave * u_amplitude +
0.45 * (1.0 - u_amplitude));
float d = length(local);
float aa = 1.0 / u_gap;
float disc = 1.0 - smoothstep(radius - aa, radius + aa, d);
// Each dot takes one flat tone from the gradient at its cell center.
vec3 color = gradientAt(((cell.x + 0.5) * u_gap) / u_resolution.x);
float alpha = disc * energy;
gl_FragColor = vec4(color * alpha, alpha);
}
`;