shadimage
neon-ui
componentbanner-patternsource: ok · refreshed

BannerPattern

The neon.com banner dot pattern as a live WebGL surface: a square-dot grid sampling a drifting green-to-amber field.

npx shadcn add "https://ui.neon.com/r/banner-pattern.json"
free

Where it goes

banner

Visual style

particleneon-cyberpunk

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light

Registry dependencies

utils

Files2 · source cached 10/8/2026, 8:09:09 AM

src/components/banner-pattern/banner-pattern.tsx
"use client";

/*
 * BannerPattern: neon.com's banner dot pattern as a live WebGL
 * surface — a square-dot grid masking a drifting color field, so
 * every dot samples the green-to-amber glow behind it. A brand
 * background for banners, cards, and covers. Static single frame
 * under reduced motion. GLSL lives in banner-pattern-shader.ts.
 */

import { useEffect, useRef } from "react";
import type { ComponentProps } from "react";

import { cn } from "@/lib/utils";

import { BANNER_FRAGMENT, BANNER_VERTEX } from "./banner-pattern-shader";

export type BannerPatternPalette = [
  base: string,
  green: string,
  sage: string,
  amber: string,
  cream: string,
  rust: string,
];

export type BannerPatternProps = Omit<ComponentProps<"canvas">, "children"> & {
  /** Animation speed multiplier; 0 freezes the field. */
  speed?: number;
  /** Grid cell size in CSS pixels. */
  cell?: number;
  /** 0-0.5 dot half-width as a fraction of the cell. */
  dotSize?: number;
  /** 0-1 unmasked ambient glow behind the dots. */
  haze?: number;
  /** 0-1 per-dot brightness variance. */
  jitter?: number;
  /** The field, painted back to front: [base, green, sage, amber, cream, rust]. */
  colors?: BannerPatternPalette;
};

/**
 * Seconds for a dial or palette change to close ~63% of its gap —
 * uniforms ease toward their targets each frame, so a dragged slider
 * or a palette switch glides instead of snapping.
 */
const SMOOTH_TAU = 0.12;

const DEFAULT_COLORS: BannerPatternPalette = [
  "#0a0b09",
  "#34d59a",
  "#97b47d",
  "#feaa2c",
  "#ffeacc",
  "#b03323",
];

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, 0];
  }

  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 BannerPattern = ({
  cell = 8,
  className,
  colors = DEFAULT_COLORS,
  dotSize = 0.14,
  haze = 0.5,
  jitter = 0.35,
  speed = 0.5,
  ...props
}: BannerPatternProps) => {
  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 uniform values, persisting across prop-driven re-inits.
  const smoothedRef = useRef<Record<string, number> | null>(null);
  const [base, green, sage, amber, cream, rust] = colors;

  useEffect(() => {
    const canvas = canvasRef.current;
    const gl = canvas?.getContext("webgl", { alpha: false });

    if (!(canvas && gl)) {
      return;
    }

    const vertex = compile(gl, gl.VERTEX_SHADER, BANNER_VERTEX);
    const fragment = compile(gl, gl.FRAGMENT_SHADER, BANNER_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 dpr = Math.min(window.devicePixelRatio || 1, 2);

    const uResolution = gl.getUniformLocation(program, "u_resolution");
    const uTime = gl.getUniformLocation(program, "u_time");
    const uCell = gl.getUniformLocation(program, "u_cell");
    const uDot = gl.getUniformLocation(program, "u_dot");
    const uHaze = gl.getUniformLocation(program, "u_haze");
    const uJitter = gl.getUniformLocation(program, "u_jitter");

    const palette = [
      ["u_base", base],
      ["u_green", green],
      ["u_sage", sage],
      ["u_amber", amber],
      ["u_cream", cream],
      ["u_rust", rust],
    ] as const;

    const targets: Record<string, number> = {
      cell,
      dotSize,
      haze,
      jitter,
      speed,
    };

    for (const [name, css] of palette) {
      const [r, g, b] = parseColor(css);
      targets[`${name}_r`] = r;
      targets[`${name}_g`] = g;
      targets[`${name}_b`] = b;
    }

    smoothedRef.current ??= { ...targets };
    const smoothed = smoothedRef.current;
    const colorLocations = palette.map(([name]) =>
      gl.getUniformLocation(program, name)
    );

    /** Ease every uniform 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(uCell, (smoothed.cell ?? cell) * dpr);
      gl.uniform1f(uDot, smoothed.dotSize ?? dotSize);
      gl.uniform1f(uHaze, smoothed.haze ?? haze);
      gl.uniform1f(uJitter, smoothed.jitter ?? jitter);

      for (const [index, [name]] of palette.entries()) {
        gl.uniform3f(
          colorLocations[index] ?? null,
          smoothed[`${name}_r`] ?? 0,
          smoothed[`${name}_g`] ?? 0,
          smoothed[`${name}_b`] ?? 0
        );
      }
    };

    let frame = 0;
    let staticFrame = false;

    const renderStatic = () => {
      applyUniforms(1);
      gl.uniform1f(uTime, 5);
      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 (e.g. behind a docs Code overlay's
      // visibility:hidden panel) — 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();
    };
  }, [
    amber,
    base,
    cell,
    cream,
    dotSize,
    green,
    haze,
    jitter,
    rust,
    sage,
    speed,
  ]);

  return (
    <canvas
      aria-hidden="true"
      className={cn("size-full", className)}
      data-slot="banner-pattern"
      ref={canvasRef}
      {...props}
    />
  );
};
src/components/banner-pattern/banner-pattern-shader.ts
/*
 * The visual shader for BannerPattern, kept separate from the React
 * mount.
 *
 * A recreation of neon.com's banner-pattern.svg, which builds its dot
 * field in two layers:
 *
 * - a color field of big blurred gradient ellipses — Neon green
 *   upper-left, sage bridging the middle, amber strengthening right,
 *   a cream hot spot in the top-right corner, rust pooling low
 * - a square-dot grid used as an alpha MASK over that field, so each
 *   dot simply samples whatever color sits behind it
 *
 * On top of the mask the source adds fractal-noise grain and a faint
 * unmasked glow; here that becomes per-dot brightness jitter and a
 * dim ambient haze of the same field behind the dots.
 *
 * Fragment shader uniforms:
 * - u_resolution (vec2): canvas resolution in pixels
 * - u_time (float): animation time in seconds (pre-multiplied by speed)
 * - u_cell (float): grid cell size in device pixels
 * - u_dot (float): 0-0.5 dot half-width as a fraction of the cell
 * - u_haze (float): 0-1 unmasked ambient glow strength
 * - u_jitter (float): 0-1 per-dot brightness variance
 * - u_base / u_green / u_sage / u_amber / u_cream / u_rust (vec3):
 *   the palette, painted back to front
 */

export const BANNER_VERTEX = `
attribute vec2 a_position;
void main() {
  gl_Position = vec4(a_position, 0.0, 1.0);
}
`;

export const BANNER_FRAGMENT = `
precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;
uniform float u_cell;
uniform float u_dot;
uniform float u_haze;
uniform float u_jitter;
uniform vec3 u_base;
uniform vec3 u_green;
uniform vec3 u_sage;
uniform vec3 u_amber;
uniform vec3 u_cream;
uniform vec3 u_rust;

float hash(vec2 p) {
  return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}

/* Gaussian falloff, elongated by the per-axis stretch. */
float blob(vec2 p, vec2 center, float radius, vec2 stretch) {
  vec2 d = (p - center) / stretch;
  return exp(-dot(d, d) / (radius * radius));
}

/* A slow elliptical orbit unique to each blob — fast enough that the
   dots visibly change color at speed 1 and clearly flow at speed 3. */
vec2 orbit(float t, float phase, float amount) {
  return vec2(cos(t * 0.3 + phase), sin(t * 0.2 + phase * 1.7)) * amount;
}

/* The color field the dots sample — blobs painted back to front. */
vec3 field(vec2 p, float aspect, float t) {
  vec3 color = u_base;

  float green = blob(
    p, vec2(0.22 * aspect, 0.98) + orbit(t, 0.0, 0.09), 0.55, vec2(1.2, 1.0));
  color = mix(color, u_green, min(green * 0.85, 1.0));

  float sage = blob(
    p, vec2(0.52 * aspect, 0.72) + orbit(t, 1.9, 0.1), 0.45, vec2(1.2, 1.0));
  color = mix(color, u_sage, min(sage * 0.6, 1.0));

  float amber = blob(
    p, vec2(0.86 * aspect, 0.78) + orbit(t, 3.4, 0.09), 0.5, vec2(1.1, 1.1));
  color = mix(color, u_amber, min(amber * 0.9, 1.0));

  float cream = blob(
    p, vec2(1.03 * aspect, 1.02) + orbit(t, 4.8, 0.06), 0.24, vec2(1.0, 1.0));
  color = mix(color, u_cream, min(cream * 0.9, 1.0));

  float rust = blob(
    p, vec2(0.92 * aspect, 0.12) + orbit(t, 5.9, 0.09), 0.45, vec2(1.35, 1.0));
  color = mix(color, u_rust, min(rust * 0.6, 1.0));

  return color;
}

/* An antialiased square dot centered in each grid cell. */
float dotMask(vec2 fragPx, float cell, float halfWidth) {
  vec2 f = abs(fract(fragPx / cell) - 0.5);
  float aa = 1.0 / cell;
  return smoothstep(halfWidth + aa, halfWidth - aa, f.x) *
    smoothstep(halfWidth + aa, halfWidth - aa, f.y);
}

void main() {
  float aspect = u_resolution.x / u_resolution.y;
  vec2 uv = gl_FragCoord.xy / u_resolution;
  vec2 p = vec2(uv.x * aspect, uv.y);
  float t = u_time;

  vec3 sampled = field(p, aspect, t);

  // Per-dot brightness jitter, the shader's stand-in for the source's
  // fractal-noise grain: each cell keeps its own fixed variance.
  vec2 cellId = floor(gl_FragCoord.xy / u_cell);
  float jitter = mix(1.0 - u_jitter * 0.5, 1.0, hash(cellId));

  float mask = dotMask(gl_FragCoord.xy, u_cell, u_dot);

  // The dots sample the field; a dim haze of the same field sits
  // behind them, like the source's unmasked glow ellipse.
  vec3 color = sampled * (u_haze * 0.18) + sampled * mask * jitter;

  gl_FragColor = vec4(color, 1.0);
}
`;

More from neon-ui

Browse all components in this library →