NoiseField
Ambient grain, film-noise, and TV-static overlay.
Live demo
01Texture, not colour.
Grain that lives over the surface, not next to it.
$ ant boot --mode=arcade > loading kernel.................. ok > mounting palette................ ok > READY.
Cinema for the cursor.
Fine
baseFreq 1.6 Β· 2 octaves
Medium
baseFreq 0.85 Β· 3 octaves
Coarse
baseFreq 0.4 Β· 4 octaves
Live playground
Tweak the props β they bind straight into the NoiseField below.
medium Β· mono
opacity 0.45 Β· animated
Implementation
02<script>
import NoiseField from '$lib/components/NoiseField.svelte';
</script>
β
<NoiseField intensity="medium" mode="mono" opacity={0.45}>
<div class="hero">
<h1>Texture, not colour.</h1>
</div>
</NoiseField>NoiseField paints SVG <feTurbulence> + <feColorMatrix> grain into a single <rect> and layers it over its slot via mix-blend-mode. Three intensities pair baseFrequency with numOctaves so grain size and richness scale together; three modes cover mono, chroma RGB, and retro chromatic-plus-scanlines. The filter ID swaps from a static SSR token to a unique nf-N on mount, so hydration mismatch never shows.
Logic explainer
03What Does It Do? (Plain English)
NoiseField wraps any region and lays a film-grain / TV-static / paper-texture overlay on top of it. The grain can be fine and tight (35 mm film), medium and editorial, or chunky and analog (VHS). It can shimmer at a 24-fps stutter, like discrete film frames sliding past, or stay completely still like a printed texture. It can render as monochrome white, saturated chromatic noise, or a chromatic-plus-scanlines retro CRT look.
Think of it as a sheet of grainy tracing paper laid over your design, with the option of a tiny stage hand jiggling it back and forth.
How It Works (Pseudo-Code)
state:
intensity = 'medium' // 'fine' | 'medium' | 'coarse'
mode = 'mono' // 'mono' | 'chroma' | 'retro'
animated = true
opacity = 0.4
filterId = 'nf-static' // SSR placeholder
reducedMotion = false
derive:
intensityConfig = pickIntensity(intensity) // { baseFrequency, numOctaves }
safeMode = pickMode(mode)
safeOpacity = clamp01(opacity)
isAnimated = animated && !reducedMotion
onMount:
filterId = nextFilterId('nf') // 'nf-1', 'nf-2', ... unique
reducedMotion = isReducedMotion()
render:
div.noisefield-wrapper
div.noisefield-content { @render children() }
div.noisefield-overlay (aria-hidden, mix-blend-mode: overlay)
svg 120% Γ 120% at -10%/-10%
<filter id={filterId}>
<feTurbulence type="fractalNoise"
baseFrequency={cfg.baseFrequency}
numOctaves={cfg.numOctaves}
seed=3 />
<feColorMatrix values=... // mono | chroma | retro
</filter>
<rect width=100% height=100% filter="url(#nf-N)" />
[if mode==='retro']
::after { background: repeating-linear-gradient(...) } // scanlines
CSS shimmer (when animated):
@keyframes noisefield-shimmer steps(8):
5 waypoints, transform: translate(Β±2%, Β±1%) and back to 0
animation: noisefield-shimmer 2.4s steps(8) infinite
// steps(8) = 8 discrete frames per cycle, no smoothing
// β looks like 24fps film, not silky 60fps slideThe component runs zero JS frame loops. The only JavaScript work is mount-time: probe reduced-motion and assign a unique filter ID. After that, the SVG renderer and the CSS compositor handle everything.
The Core Concept: feTurbulence + Stepped Animation
Two ideas combine to produce the visual.
feTurbulence β procedural noise on the GPU
<feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="3" seed="3" />feTurbulence generates a Perlin-noise field β a smooth, organic, randomly-shaped image where nearby pixels have similar values, but the field as a whole has no repeating pattern. Two parameters tune it:
baseFrequencyβ how coarse the noise is. Low values produce big blobs of darkness and lightness; high values produce fine-grained crinkle. The component flips this counter-intuitively:finemode uses higher frequency (1.6) for tight grain;coarsemode uses lower frequency (0.4) for big chunky grain.numOctavesβ how many layers of noise are summed together at half-scale each. More octaves means richer detail (multiple sizes of grain visible at once) but slightly more expensive to compute. The component scales octaves with intensity: 2 β 3 β 4 from fine to coarse.
seed=3 makes the noise field deterministic. Server and client render the same first frame; multiple instances on the same page render the same noise (which is what you want β different seeds would produce different "grain" on each, breaking visual consistency).
feColorMatrix β colour mode switching
feTurbulence produces a grayscale field. feColorMatrix re-colours it without changing its shape:
<!-- mono: all channels = 1, alpha preserved -->
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0" />
<!-- chroma: slight R/B amplification, neutral G -->
<feColorMatrix type="matrix" values="1.2 0 0 0 0 0 1.0 0 0 0 0 0 1.4 0 0 0 0 0 1 0" />
<!-- retro: cyan-shifted with channel mix and small additive offset -->
<feColorMatrix type="matrix" values="0.7 0 0 0 0.1 0 0.9 0 0 0.15 0 0 1.0 0 0.2 0 0 0 1 0" />The matrix is a 4 Γ 5 transform on [R, G, B, A] (with the 5th column being a constant offset added in). Mono crushes RGB to white, alpha kept. Chroma scales channels independently for a saturated look. Retro adds a small constant offset (the 0.1 / 0.15 / 0.2 last column) to push the noise toward cyan.
Stepped animation = film-frame stutter
.noisefield-overlay.animated svg {
animation: noisefield-shimmer 2.4s steps(8) infinite;
}steps(8) is the unusual choice. Most CSS animations use ease, linear, or a cubic-bezier for smooth interpolation. steps(8) does the opposite β it divides the 2.4 s cycle into 8 discrete frames and snaps between them with no easing. The result reads as a film projector advancing through individual frames, not as a silky slide.
The keyframe itself is just five offset waypoints:
@keyframes noisefield-shimmer {
0%, 100% { transform: translate(0, 0); }
20% { transform: translate(-2%, 1%); }
40% { transform: translate(1%, -2%); }
60% { transform: translate(-1%, -1%); }
80% { transform: translate(2%, 0); }
}The 120 % oversize on the SVG (positioned at -10%, -10%) means even a 2 % translate never reveals an empty edge. The translate is on the SVG host, not the noise itself β the filter chain doesn't recompute, only the composited output slides. This is what keeps the animation cheap.
The retro scanline overlay
Mode retro adds a sibling pseudo-element with a hard-edged stripe pattern:
.noisefield-overlay.retro::after {
background: repeating-linear-gradient(
to bottom,
rgba(0, 0, 0, 0.22) 0px,
rgba(0, 0, 0, 0.22) 1px,
transparent 1px,
transparent 3px
);
mix-blend-mode: multiply;
}A 1 px-on / 2 px-off stripe pattern. mix-blend-mode: multiply darkens whatever's beneath, which combines with the chromatic noise to produce the classic CRT-bleed-through look without a separate render pass.
Performance
- One
<svg>per instance, containing one<filter>and one<rect>. No SVG DOM is added or removed during animation. - Filter computation runs on the GPU (in modern browsers' SVG renderer).
feTurbulenceis the only meaningfully expensive primitive; with 2β4 octaves on a single full-bleed surface it's a few ms per filter recomputation, well within budget. - Filter recomputes only when its inputs change β
baseFrequencyandnumOctavesare static once computed fromintensity. The shimmer animation translates the SVG host, not the filter inputs, so the filter is computed once and its output is shifted. mix-blend-mode: overlayon the overlay is compositor-only β the GPU multiplies the noise into the underlying content without re-painting the source.- No observers, no rAF, no resize handlers. The 120 % SVG sizing makes layout-invariant.
steps(8)animation has 8 discrete states per cycle; the GPU only composites a new frame at each step (every 300 ms), so the animation is cheaper than a smooth 60 fps transform would be.
State Flow Diagram
ββββββββββββββββββββββββββββββββ
β SSR / first paint β
β filterId = 'nf-static' β
β reducedMotion = false β
β noise renders with mono β
β colour matrix β
ββββββββββββββ¬ββββββββββββββββββ
β onMount
βΌ
ββββββββββββββββββββββββββββββββ
β filterId = nextFilterId() β
β reducedMotion = probed β
ββββββββββββββ¬ββββββββββββββββββ
β
ββββββββββββββ΄βββββββββββββββββ
β β
β animated && !reducedMotion β animated=false OR reducedMotion
βΌ βΌ
ββββββββββββββββ ββββββββββββββββ
β shimmering β β static β
β .animated β β no .animatedβ
β on overlay; β β class; β
β steps(8) β β noise frozenβ
β keyframe β β at 0,0 β
ββββββββββββββββ ββββββββββββββββ
prefers-reduced-motion: reduce βββΊ CSS @media kills shimmer keyframe
(belt-and-braces alongside JS)Props Reference
| Prop | Type | Default | Description |
|---|---|---|---|
intensity |
'fine' | 'medium' | 'coarse' |
'medium' |
Bundles feTurbulence baseFrequency and numOctaves. Unknown names fall back to medium. |
mode |
'mono' | 'chroma' | 'retro' |
'mono' |
Colour-matrix preset; retro adds a scanline overlay. Unknown names fall back to mono. |
animated |
boolean |
true |
Auto-disabled if prefers-reduced-motion: reduce. |
opacity |
number |
0.4 |
Overlay opacity. Clamped to [0, 1]; NaN / Β±Infinity collapse to 0. |
class |
string |
'' |
Extra classes on the wrapper. |
children |
Snippet |
optional | Content to wrap. Stays interactive. |
Edge Cases
| Situation | Behaviour |
|---|---|
Unknown intensity or mode |
Falls back to medium / mono via pickIntensity / pickMode. |
opacity = NaN / Β±Infinity |
clamp01 returns 0 β overlay invisible rather than throwing. |
prefers-reduced-motion: reduce set on mount |
isAnimated derives false; CSS @media rule also kills the shimmer keyframe as a guard. Static grain remains. |
| Multiple instances on one page | Each gets a unique filter ID via nextFilterId('nf') β no SVG ID collisions. |
| Component scrolled offscreen | Browser throttles the compositor layer; CSS animation pauses naturally on hidden tabs. |
| Wrapper resized | SVG is sized at 120% Γ 120% β naturally adapts to any wrapper size. No ResizeObserver needed. |
| Hi-DPI / retina | feTurbulence is resolution-independent; noise scales with the SVG's CSS pixels. The chroma and retro modes can show colour fringing at very high DPI on some browsers; mono is the most robust at extreme resolutions. |
| GPU acceleration unavailable | feTurbulence falls back to CPU; cost rises but stays usable. Drop intensity to coarse for cheaper octaves. |
| Browser without SVG filters (IE11) | <filter> is ignored; the <rect> renders as a black square. The mix-blend-mode: overlay makes that imperceptible. |
mode="retro" on a Safari version without mix-blend-mode: multiply |
Scanline overlay renders at full opacity instead of multiplying β uglier but still visible. Modern Safari (β₯15) supports it. |
children not provided |
{#if children} guard skips the content slot; only the noise overlay renders. Useful for full-page background grain. |
| Component used inside a position-fixed container | Fine β the SVG host is position: absolute; inset: 0 relative to the wrapper, not the viewport. |
Dependencies
- Svelte 5.x β
$props,$state,$derived, snippets. Module-script exports (pickIntensity,pickMode,clamp01,nextFilterId, etc.) for unit testing. - Zero external dependencies β inline SVG filter, CSS keyframes. No image assets, no canvas, no animation library.
File Structure
src/lib/components/NoiseField.svelte # implementation + module-level helpers
src/lib/components/NoiseField.md # this file (rendered inside ComponentPageShell)
src/lib/components/NoiseField.test.ts # vitest unit tests
src/lib/components/NoiseFieldTestHarness.test.svelte # render-test harness
src/routes/noisefield/+page.svelte # demo pageAPI
04| Prop | Type | Default | Description |
|---|---|---|---|
intensity | 'fine' | 'medium' | 'coarse' | 'medium' | Bundles baseFrequency and numOctaves. |
mode | 'mono' | 'chroma' | 'retro' | 'mono' | Colour matrix preset; retro adds scanlines. |
animated | boolean | true | Set false (or under reduced-motion) for a static frame. |
opacity | number | 0.4 | Overlay opacity, 0β1. |