noiseTexture
A Backgrounds block/component from Magic UI — clean-room reimplemented for Domphy (see methodology). Call noiseTexture() with no arguments for a working demo, or edit the code below live.
Implementation notes
GENUINE CORE-LEVEL BLOCKER, not a design choice: the spec's domSketch requires an SVG <feTurbulence> fractal-noise filter (chained through feColorMatrix/feComponentTransfer). Verified in packages/core/src/constants/SvgTags.ts that feTurbulence, feComponentTransfer, and feFuncR/G/B/A are ABSENT from the SvgTags allowlist that ElementNode._createDOMNode consults to decide document.createElementNS(svgNS, tag) vs. plain document.createElement(tag) — even though those exact tag names ARE present in HtmlTags (so doctor's unknown-tag rule stays silent and the element 'looks' fine, but at runtime it renders as an inert, unnamespaced HTMLUnknownElement in a real browser, and the SVG filter produces zero noise). This is a real, verifiable gap in @domphy/core today, not an approximation choice on my part — confirmed by reading both constant files directly. Per this port's own escape-hatch rule ('never fabricate a false ported status'), I did not modify @domphy/core (out of scope for a packages/blocks task; a shared package with wide blast radius) and instead reimplemented the component as a <canvas> grayscale grain generator with an equivalent public API (frequency/octaves/slope/noiseOpacity/seed) and the same visual result (static, deterministic-per-seed, desaturated speckled grain, mix-blend-mode over the content beneath, matching the spec's 'reads as a texture multiply' wording). Canvas draw is guarded with if (!context) return — the exact same jsdom-canvas-unavailable fallback pattern already used by iconCloud()/particles() in this package, confirmed by reading their source. A literal SVG-filter version becomes possible with a one-line addition to @domphy/core's SvgTags array (recommend as a follow-up, out of this task's scope). doctor CLI: 0 diagnostics on the canvas-based implementation actually shipped.
Status: partial · Reference: Magic UI original
// magicui "Noise Texture" — clean-room reimplementation from the public
// behavior/visual spec only (no upstream source viewed or copied). A thin
// overlay rendering fine grayscale film-grain/static noise, meant to be
// layered over cards, buttons, or backgrounds for texture.
//
// FIDELITY GAP — read before assuming this is a literal SVG-filter port:
// the spec's domSketch calls for an SVG `<feTurbulence>` fractal-noise
// filter chained through `<feColorMatrix>`/`<feComponentTransfer>`. Domphy
// core's `SvgTags` allowlist (`packages/core/src/constants/SvgTags.ts` —
// the table `ElementNode._createDOMNode` consults to decide whether to
// `document.createElementNS` an element into the SVG namespace, versus
// falling back to a plain unnamespaced `document.createElement`) does not
// include `feTurbulence`, `feComponentTransfer`, or `feFuncR/G/B/A`, even
// though those tag names ARE recognized elsewhere (`HtmlTags`, so doctor's
// `unknown-tag` rule stays silent and `getTagName` resolves them fine).
// Concretely: `{ feTurbulence: null, ... }` would render as an inert,
// unnamespaced `HTMLUnknownElement` in a real browser — SVG filter
// primitives only take effect inside the SVG namespace — so the filter
// would silently produce zero noise. Rather than ship a component that
// renders nothing, this is reimplemented as a `<canvas>` grayscale grain
// generator with an equivalent public API (`frequency`/`octaves`/`slope`/
// `noiseOpacity`) and the same visual result: a static, desaturated,
// speckled grain field. A literal SVG-filter version would become possible
// with a one-line addition to `SvgTags` in `@domphy/core` (out of scope for
// this package).
import type { DomphyElement, ElementNode, StyleObject } from "@domphy/core";
import { paragraph, strong } from "@domphy/ui";
import { themeColor, themeSpacing } from "@domphy/theme";
export interface NoiseTextureProps {
/** Controls grain fineness — higher values produce smaller, finer speckles. Defaults to `0.4`. */
frequency?: number;
/** Number of blended fractal-noise layers (finer accumulated detail per layer). Defaults to `6`. */
octaves?: number;
/** Brightness multiplier applied to the grayscale grain, controlling contrast/intensity. Defaults to `0.15`. */
slope?: number;
/** Overall opacity of the noise layer. Defaults to `0.6`. */
noiseOpacity?: number;
/** Deterministic seed for the noise field — the same seed reproduces the same grain.
* Defaults to a per-instance value (so repeated calls without an explicit seed still differ). */
seed?: number;
/** Content the noise layer is composited over. Defaults to a small demo panel. */
children?: DomphyElement | DomphyElement[];
/** Passthrough style merged onto the outer wrapper. */
style?: StyleObject;
}
let noiseTextureInstanceCounter = 0;
/** Seeded pseudo-random generator (mulberry32) — deterministic per seed, no external RNG dependency. */
function createSeededRandom(seed: number): () => number {
let state = seed >>> 0;
return () => {
state = (state + 0x6d2b79f5) >>> 0;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** Draws one static grayscale grain field into the canvas, sized to its current bounding box.
* Multi-octave value noise (not Perlin) summed with halving amplitude per octave, approximating
* the layered-detail read of a real fractal-noise filter without needing one. */
function drawNoise(canvas: HTMLCanvasElement, frequency: number, octaves: number, slope: number, seed: number): void {
const context = canvas.getContext("2d");
if (!context) return;
const width = Math.max(1, Math.round(canvas.clientWidth || 1));
const height = Math.max(1, Math.round(canvas.clientHeight || 1));
canvas.width = width;
canvas.height = height;
// Higher frequency → smaller grain cells (finer speckle); lower → coarser blobs.
const cellSize = Math.max(1, Math.min(40, Math.round(2 / Math.max(0.02, frequency))));
const random = createSeededRandom(seed);
const octaveCount = Math.max(1, Math.round(octaves));
for (let y = 0; y < height; y += cellSize) {
for (let x = 0; x < width; x += cellSize) {
let value = 0;
let amplitude = 1;
let amplitudeTotal = 0;
for (let octave = 0; octave < octaveCount; octave += 1) {
value += random() * amplitude;
amplitudeTotal += amplitude;
amplitude *= 0.5;
}
const normalized = amplitudeTotal > 0 ? value / amplitudeTotal : 0;
const gray = Math.max(0, Math.min(255, Math.round(normalized * 255 * slope * 4)));
// Equal R/G/B by construction — fully desaturated grayscale, no separate
// feColorMatrix desaturation step needed.
context.fillStyle = `rgb(${gray}, ${gray}, ${gray})`;
context.fillRect(x, y, cellSize, cellSize);
}
}
}
function defaultChildren(): DomphyElement[] {
return [
{ strong: "Textured surface", $: [strong()] } as DomphyElement,
{
p: "A subtle desaturated grain layered over this panel via canvas, not an SVG filter — see the fidelity note in this file.",
$: [paragraph()],
} as DomphyElement,
];
}
/**
* Overlay rendering fine grayscale film-grain/static noise, meant to be
* layered over cards, buttons, or backgrounds for texture. Implemented as a
* `<canvas>` grain generator (see the fidelity note above the module for
* why — Domphy core doesn't yet namespace the SVG filter-primitive tags the
* upstream technique needs). Static by default: one fixed noise field per
* mount/seed; any fade-in/out is left to the caller's own CSS transition on
* hover/group-hover, same as the upstream pattern. Call with no arguments
* for a working demo — a small textured panel.
*/
function noiseTexture(props: NoiseTextureProps = {}): DomphyElement<"div"> {
const instanceId = ++noiseTextureInstanceCounter;
const frequency = props.frequency ?? 0.4;
const octaves = props.octaves ?? 6;
const slope = props.slope ?? 0.15;
const noiseOpacity = props.noiseOpacity ?? 0.6;
const seed = props.seed ?? instanceId * 97 + 1;
const contentChildren = props.children
? Array.isArray(props.children)
? props.children
: [props.children]
: defaultChildren();
const canvasElement: DomphyElement<"canvas"> = {
canvas: null,
ariaHidden: "true",
_onMount: (node: ElementNode) => {
const canvas = node.domElement as unknown as HTMLCanvasElement;
const redraw = () => drawNoise(canvas, frequency, octaves, slope, seed);
redraw();
let observer: ResizeObserver | null = null;
if (typeof ResizeObserver === "function") {
observer = new ResizeObserver(() => redraw());
observer.observe(canvas);
}
node.addHook("Remove", () => {
observer?.disconnect();
observer = null;
});
},
style: {
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
pointerEvents: "none",
// Reads as a texture multiply over whatever content sits beneath.
mixBlendMode: "multiply",
opacity: noiseOpacity,
} as StyleObject,
} as DomphyElement<"canvas">;
return {
div: [{ div: contentChildren, style: { position: "relative" } }, canvasElement],
dataTone: "shift-1",
style: {
position: "relative",
overflow: "hidden",
borderRadius: themeSpacing(4),
padding: themeSpacing(8),
minHeight: themeSpacing(40),
backgroundColor: (listener) => themeColor(listener, "inherit"),
color: (listener) => themeColor(listener, "shift-9"),
...(props.style ?? {}),
} as StyleObject,
};
}
export { noiseTexture };