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.

Usage

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

import { useEffect, useId, useMemo, useRef, useState } from "react";
import { NavBlueNoiseScrim } from "./component";

const COMMANDS = [
  "Go to Dashboard",
  "Go to Deployments",
  "Go to Analytics",
  "Search repository",
  "Create new project",
  "Invite teammate",
  "Toggle dark theme",
  "Open settings",
];

// A minimal command-palette overlay so the scrim has a real job: dim the
// page behind it while the dialog's own opaque panel carries the readable
// content. Escape closes, Tab is trapped on the single input (the palette's
// only interactive control), and the trigger button regains focus on close.
export default function NavBlueNoiseScrimDemo() {
  const [open, setOpen] = useState(true);
  const [query, setQuery] = useState("");
  const triggerRef = useRef<HTMLButtonElement>(null);
  const inputRef = useRef<HTMLInputElement>(null);
  const listId = useId();

  const results = useMemo(() => {
    const q = query.trim().toLowerCase();
    return q ? COMMANDS.filter((c) => c.toLowerCase().includes(q)) : COMMANDS;
  }, [query]);

  useEffect(() => {
    if (open) inputRef.current?.focus();
  }, [open]);

  const close = () => {
    setOpen(false);
    triggerRef.current?.focus();
  };

  return (
    <div className="relative flex min-h-screen flex-col bg-background">
      {/* app chrome underneath, so the scrim is dimming real page content */}
      <header className="flex items-center justify-between border-b border-border px-6 py-4">
        <p className="font-mono text-xs tracking-[0.25em] text-ns-muted">
          ns-ui / nav-blue-noise-scrim
        </p>
        <button
          ref={triggerRef}
          type="button"
          aria-haspopup="dialog"
          aria-expanded={open}
          onClick={() => setOpen((v) => !v)}
          className="rounded-sm border border-border bg-surface px-3 py-1.5 font-mono text-xs text-ns-muted transition-colors hover:border-foreground/30 hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent"
        >
          Search ⌘K
        </button>
      </header>

      <main className="mx-auto max-w-3xl flex-1 px-6 py-16">
        <h1 className="text-3xl font-semibold tracking-tight text-foreground">
          Blue Noise Scrim
        </h1>
        <p className="mt-3 max-w-xl text-sm leading-relaxed text-ns-muted">
          The dimmed backdrop behind the palette is a temporal blue-noise
          dither: a void-and-cluster point mask, reshuffled every frame, that
          reads as fine aperiodic shimmer instead of the visible crosshatch
          of an ordered/Bayer dither. Open the palette to see it behind the
          panel — the panel itself stays fully opaque, so the grain never
          touches the text on top of it.
        </p>
      </main>

      {open ? (
        <div
          className="absolute inset-0 z-10"
          onKeyDown={(e) => {
            if (e.key === "Escape") {
              e.preventDefault();
              close();
            } else if (e.key === "Tab") {
              // focus trap: the input is the palette's single tab stop
              e.preventDefault();
              inputRef.current?.focus();
            }
          }}
        >
          <NavBlueNoiseScrim className="absolute inset-0" />
          <div
            role="dialog"
            aria-modal="true"
            aria-label="Command palette"
            className="absolute left-1/2 top-24 w-full max-w-md -translate-x-1/2 overflow-hidden rounded-md border border-border bg-surface shadow-lg"
          >
            <input
              ref={inputRef}
              role="combobox"
              aria-expanded="true"
              aria-controls={listId}
              aria-autocomplete="list"
              aria-label="Search commands"
              autoComplete="off"
              spellCheck={false}
              value={query}
              placeholder="Type a command…"
              onChange={(e) => setQuery(e.target.value)}
              className="w-full border-b border-border bg-transparent px-4 py-3 text-sm text-foreground outline-none placeholder:text-ns-muted"
            />
            <ul id={listId} role="listbox" aria-label="Commands" className="max-h-72 overflow-y-auto py-1">
              {results.length ? (
                results.map((c) => (
                  <li
                    key={c}
                    role="option"
                    aria-selected="false"
                    className="cursor-pointer px-4 py-2 text-sm text-ns-muted transition-colors hover:bg-background hover:text-foreground"
                  >
                    {c}
                  </li>
                ))
              ) : (
                <li className="px-4 py-6 text-center text-sm text-ns-muted">
                  No matching commands
                </li>
              )}
            </ul>
          </div>
        </div>
      ) : null}
    </div>
  );
}

Component

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}`}
    />
  );
}

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/nav-blue-noise-scrim.tsx, so point the import there.

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.

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?string—extra classes merged onto the rendered root element