Skip to main content

ns-ui

Nav Blue Noise Scrim

Dimming backdrop for a command palette or search overlay, dithered with a temporal blue-noise mask that reshuffles every frame instead of a flat tint.

Use when the dimmed backdrop behind a command palette or search overlay needs to feel alive at rest without competing with the panel's text; pick background-ascii-dither instead when the dither is the content itself (an ASCII/Bayer/dot media renderer), or chart-*-dither when the dither is encoding a data value rather than acting as scrim texture.

Install

npx shadcn add https://design.helpmarq.com/r/nav-blue-noise-scrim.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

Source
registry/core/nav-blue-noise-scrim/component.tsx
"use client";

import { useLayoutEffect, useRef } from "react";

// ---------------------------------------------------------------------------
// NavBlueNoiseScrim — the dimming backdrop behind a command palette / search
// overlay, rendered as a TEMPORAL blue-noise dither instead of a flat tint.
//
// Spatial mask: a real void-and-cluster point distribution (Ulichney 1993),
// computed once per matrix size. Unlike the repo's ordered-dither family
// (background-ascii-dither's Bayer mode, chart-*-dither), which threshold
// against a small regular 4x4 matrix and read as a visible crosshatch at any
// zoom, void-and-cluster iteratively balances a binary pattern against its
// own Gaussian-blurred energy so no two "on" cells ever cluster and no
// region goes empty for long — an aperiodic, non-repeating-looking point set
// with no dominant frequency. That produces a RANK for every cell in the
// matrix (0..N-1): thresholding "rank < coverage * N" at any coverage level
// yields a blue-noise-distributed dot set, which is the mechanism ordered
// dithering is built on but with a fundamentally different point-generation
// algorithm and a different visual signature (fine, grain-like, isotropic —
// not a lattice).
//
// Temporal reshuffle: real-time renderers get "temporal blue noise" by
// pairing a spatial blue-noise mask with a decorrelated per-frame offset
// (the animated-noise / golden-ratio-sequence trick used for TAA dithering,
// e.g. Playdead's INSIDE, Jorge Jimenez's "Interleaved Gradient Noise").
// Every rAF tick we advance a frame-offset by the golden-ratio conjugate mod
// 1 and threshold `frac(rank / N + offset) < coverage` — every cell cycles
// through the full rank order over N frames, decorrelated frame to frame,
// so which dots are lit changes completely each frame while the SPATIAL
// distribution of "on" cells stays blue-noise at every instant. This is an
// honest approximation of full spatiotemporal blue noise (STBN, which needs
// a pre-baked 3D noise volume) built from one 2D void-and-cluster mask, not
// a from-scratch spatiotemporal solve.
// ---------------------------------------------------------------------------

const MATRIX_SIZE = 32; // 1024-cell void-and-cluster tile
const GOLDEN_CONJUGATE = 0.6180339887498949;

// module-level cache: the matrix is expensive-ish (O(n^2) balancing passes)
// and fully deterministic for a fixed size, so every mounted instance shares
// one computation instead of repeating it per scrim.
let cachedRanks: Uint32Array | null = null;

function computeVoidClusterRanks(size: number): Uint32Array {
  const n = size * size;
  const R = 2; // filter radius (5x5 support)
  const sigma = 1.5;
  const kernel: number[] = [];
  for (let dy = -R; dy <= R; dy++) {
    for (let dx = -R; dx <= R; dx++) {
      kernel.push(Math.exp(-(dx * dx + dy * dy) / (2 * sigma * sigma)));
    }
  }
  const idx = (x: number, y: number) =>
    ((y + size) % size) * size + ((x + size) % size);

  const addEnergy = (
    energy: Float32Array,
    x: number,
    y: number,
    sign: number
  ) => {
    let k = 0;
    for (let dy = -R; dy <= R; dy++) {
      for (let dx = -R; dx <= R; dx++) {
        energy[idx(x + dx, y + dy)] += sign * kernel[k++];
      }
    }
  };

  const tightestCluster = (pattern: Uint8Array, energy: Float32Array) => {
    let best = -1;
    let bestE = -Infinity;
    for (let i = 0; i < n; i++) {
      if (pattern[i] === 1 && energy[i] > bestE) {
        bestE = energy[i];
        best = i;
      }
    }
    return best;
  };
  const largestVoid = (pattern: Uint8Array, energy: Float32Array) => {
    let best = -1;
    let bestE = Infinity;
    for (let i = 0; i < n; i++) {
      if (pattern[i] === 0 && energy[i] < bestE) {
        bestE = energy[i];
        best = i;
      }
    }
    return best;
  };

  // deterministic seed so the mask is stable across reloads/screenshots
  let seed = 0x9e3779b9;
  const rand = () => {
    seed = (seed + 0x6d2b79f5) >>> 0;
    let t = seed;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };

  const pattern = new Uint8Array(n);
  const energy = new Float32Array(n);
  const initialCount = Math.max(2, Math.round(n * 0.1));
  const ones = new Set<number>();
  while (ones.size < initialCount) ones.add(Math.floor(rand() * n));
  for (const i of ones) {
    pattern[i] = 1;
    addEnergy(energy, i % size, Math.floor(i / size), 1);
  }

  // Phase 1 — balance the initial pattern: swap the tightest cluster for the
  // largest void until a swap would just reverse itself (converged).
  for (let iter = 0; iter < initialCount * 4; iter++) {
    const cluster = tightestCluster(pattern, energy);
    pattern[cluster] = 0;
    addEnergy(energy, cluster % size, Math.floor(cluster / size), -1);
    const voidCell = largestVoid(pattern, energy);
    if (voidCell === cluster) {
      pattern[cluster] = 1;
      addEnergy(energy, cluster % size, Math.floor(cluster / size), 1);
      break;
    }
    pattern[voidCell] = 1;
    addEnergy(energy, voidCell % size, Math.floor(voidCell / size), 1);
  }

  const ranks = new Uint32Array(n);
  const balancedCount = pattern.reduce((a, b) => a + b, 0);
  // snapshot the converged, balanced pattern before phase 2 consumes it —
  // phase 3 restarts from this exact state rather than re-deriving it.
  const p3 = pattern.slice();
  const e3 = energy.slice();

  // Phase 2 — rank the balanced ones downward (n0-1 .. 0): repeatedly strip
  // the tightest cluster, so the most-clustered cell gets the lowest rank
  // and empties first as coverage shrinks.
  const p2 = pattern;
  const e2 = energy;
  let rank = balancedCount - 1;
  while (rank >= 0) {
    const cluster = tightestCluster(p2, e2);
    p2[cluster] = 0;
    addEnergy(e2, cluster % size, Math.floor(cluster / size), -1);
    ranks[cluster] = rank;
    rank--;
  }

  // Phase 3 — rank the remaining zeros upward (n0 .. N-1) from the snapshot
  // of the balanced pattern: repeatedly fill the largest void, so cells fill
  // in the order that keeps the pattern most evenly spread at every coverage.
  rank = balancedCount;
  while (rank < n) {
    const voidCell = largestVoid(p3, e3);
    p3[voidCell] = 1;
    addEnergy(e3, voidCell % size, Math.floor(voidCell / size), 1);
    ranks[voidCell] = rank;
    rank++;
  }

  return ranks;
}

export interface NavBlueNoiseScrimProps {
  /** grain cell size in px — kept fine and proportional, never a coarse checkerboard */
  cellSize?: number;
  /** mean fraction of cells lit at any instant, 0..1 */
  coverage?: number;
  /** extra classes merged onto the rendered root element */
  className?: string;
}

export function NavBlueNoiseScrim({
  cellSize = 4,
  coverage = 0.07,
  className = "",
}: NavBlueNoiseScrimProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  // token derive happens in useLayoutEffect, before first paint, so no rAF /
  // ResizeObserver / reduced-motion branch can draw with an empty ink string
  useLayoutEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    const reduced = window.matchMedia(
      "(prefers-reduced-motion: reduce)"
    ).matches;

    let bg = "";
    let fg = "";
    const readTokens = () => {
      const cs = getComputedStyle(document.documentElement);
      bg = cs.getPropertyValue("--background").trim();
      fg = cs.getPropertyValue("--foreground").trim();
    };
    readTokens();

    if (!cachedRanks) cachedRanks = computeVoidClusterRanks(MATRIX_SIZE);
    const ranks = cachedRanks;
    const n = MATRIX_SIZE * MATRIX_SIZE;

    let dpr = 1;
    let cols = 0;
    let rows = 0;
    let cssW = 0;
    let cssH = 0;
    let raf = 0;
    let frame = 0;
    let disposed = false;

    const resize = () => {
      dpr = Math.min(window.devicePixelRatio || 1, 2);
      const rect = canvas.getBoundingClientRect();
      cssW = rect.width;
      cssH = rect.height;
      canvas.width = Math.max(1, Math.round(cssW * dpr));
      canvas.height = Math.max(1, Math.round(cssH * dpr));
      cols = Math.ceil(cssW / cellSize);
      rows = Math.ceil(cssH / cellSize);
    };

    let resizeTimer: ReturnType<typeof setTimeout> | null = null;
    const ro = new ResizeObserver(() => {
      if (resizeTimer) clearTimeout(resizeTimer);
      resizeTimer = setTimeout(() => {
        resizeTimer = null;
        resize();
        if (reduced) draw(0);
      }, 100);
    });
    ro.observe(canvas);

    // base dim: a controlled, flat --background wash so the scrim reads as
    // "behind the dialog" even where no grain cell lands this frame — the
    // grain is texture on TOP of legibility, not the source of it. The
    // dialog panel itself (see demo) is fully opaque, so nothing drawn here
    // ever touches its text regardless of coverage or alpha.
    const BASE_DIM_ALPHA = 0.55;
    const GRAIN_ALPHA = 0.5;

    const draw = (offset: number) => {
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx.clearRect(0, 0, cssW, cssH);
      ctx.fillStyle = bg;
      ctx.globalAlpha = BASE_DIM_ALPHA;
      ctx.fillRect(0, 0, cssW, cssH);

      ctx.fillStyle = fg;
      ctx.globalAlpha = GRAIN_ALPHA;
      for (let gy = 0; gy < rows; gy++) {
        const my = gy % MATRIX_SIZE;
        for (let gx = 0; gx < cols; gx++) {
          const mx = gx % MATRIX_SIZE;
          const rank = ranks[my * MATRIX_SIZE + mx];
          const t = (rank / n + offset) % 1;
          if (t < coverage) {
            ctx.fillRect(gx * cellSize, gy * cellSize, cellSize, cellSize);
          }
        }
      }
      ctx.globalAlpha = 1;
    };

    const loop = () => {
      frame++;
      const offset = (frame * GOLDEN_CONJUGATE) % 1;
      draw(offset);
      if (!document.hidden) raf = requestAnimationFrame(loop);
    };

    const onVis = () => {
      if (!document.hidden && !reduced) {
        raf = requestAnimationFrame(loop);
      }
    };
    document.addEventListener("visibilitychange", onVis);

    // theme flip re-reads tokens live; frozen reduced-motion frame repaints
    const mo = new MutationObserver(() => {
      readTokens();
      if (reduced) draw(0);
    });
    mo.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class"],
    });

    document.fonts.ready.then(() => {
      if (disposed) return;
      resize();
      if (reduced) {
        // prefers-reduced-motion: freeze to a single static blue-noise mask
        // instance (offset 0) rather than reshuffling every frame — still
        // aperiodic and still distinct from Bayer, just not animated. A
        // per-frame reshuffling grain is exactly the kind of motion this
        // media query exists to suppress.
        draw(0);
      } else {
        raf = requestAnimationFrame(loop);
      }
    });

    return () => {
      disposed = true;
      cancelAnimationFrame(raf);
      if (resizeTimer) clearTimeout(resizeTimer);
      ro.disconnect();
      mo.disconnect();
      document.removeEventListener("visibilitychange", onVis);
    };
  }, [cellSize, coverage]);

  return (
    <canvas
      ref={canvasRef}
      aria-hidden="true"
      className={`pointer-events-none block h-full w-full ${className}`}
    />
  );
}
Build spec

Build a full-bleed canvas scrim for the area behind a command palette or search overlay, dithered with a real void-and-cluster blue-noise point distribution (Ulichney 1993) instead of a flat --background tint. Void-and-cluster iteratively balances a binary pattern against its own Gaussian-blurred energy so 'on' cells never cluster and no region empties out, producing a per-cell RANK (0..N-1) over a 32x32 tile: thresholding rank against any coverage level yields a blue-noise-distributed dot set. This is a different point-generation algorithm from the repo's ordered-dither family (background-ascii-dither's Bayer mode, the chart-*-dither components), which threshold against a small regular 4x4 matrix and read as a visible crosshatch lattice at any zoom — void-and-cluster's aperiodic, non-lattice point set reads as fine isotropic grain instead, the distinction the dithering literature draws between ordered and blue-noise dither families. TEMPORAL layer: every rAF tick advances a frame offset by the golden-ratio conjugate mod 1 and thresholds frac(rank/N + offset) < coverage, so every cell cycles through the full blue-noise rank order over N frames, decorrelated frame to frame — the animated-noise / golden-ratio-sequence technique real-time renderers use for TAA dithering, an honest approximation of full spatiotemporal blue noise (which needs a pre-baked 3D noise volume) built from one 2D mask rather than a from-scratch spatiotemporal solve. A scrim has a job: content sits on top of it and must stay readable. The canvas paints a controlled flat --background wash (alpha 0.55) as the base dim, with the blue-noise grain layered on top at a fine cellSize (default 4px) and low alpha (0.5) in --foreground — texture on top of legibility, not the source of it; the overlay panel itself stays fully opaque so grain never touches its text regardless of coverage. Coverage defaults to 0.07 (7% of cells lit at any instant) so the mean shimmer stays subtle. Token colors (--background, --foreground) are read via getComputedStyle(document.documentElement) inside useLayoutEffect, before first paint, and re-read on a MutationObserver watching documentElement's class — no color field is ever used before that read. prefers-reduced-motion freezes to a single static blue-noise mask instance (frame offset 0) rather than reshuffling every frame: still aperiodic and still distinct from Bayer, just not animated, since a per-frame reshuffling grain is exactly the motion this media query exists to suppress. ResizeObserver (debounced 100ms) drives cols/rows so the grain cell size stays fine and proportional across viewport sizes rather than becoming a coarse checkerboard in a small container; the render loop pauses on document visibilitychange. The canvas itself is aria-hidden and pointer-events:none — purely decorative scrim texture; the demo's surrounding dialog owns real overlay semantics (role=dialog, aria-modal, Escape closes, Tab trapped on its single input). Props: cellSize (default 4), coverage (default 0.07), className.

Props

PropTypeDefaultDescription
cellSize?number4grain cell size in px — kept fine and proportional, never a coarse checkerboard
coverage?number0.07mean fraction of cells lit at any instant, 0..1
className?stringextra classes merged onto the rendered root element