I spent an afternoon adjusting a single baseFrequency value in an SVG filter just to see what it did. That was the first time I understood that feTurbulence wasn’t a visual effect — it was a parameter space. Lower the value and you’re zooming out on a noise field. Raise it and the texture becomes fine-grained and chaotic. Add a second value and x and y scale independently, which is how you get that directional grain you see on printed paper.
The browser has had these tools for years — they just don’t show up in any tutorial I’ve seen.
SVG filters on any element
The thing that took me longest to notice: the filter CSS property accepts a URL reference to an SVG <filter> definition, and that filter runs on any element — not just SVG content. You define a noise texture in an invisible <svg> block and apply it via CSS to a <div>, a heading, a section background. The effect pipeline runs on the rendered pixels, completely separate from the DOM structure.
<svg style="display: none">
<defs>
<filter id="noise">
<feTurbulence
type="fractalNoise"
baseFrequency="0.65"
numOctaves="3"
stitchTiles="stitch"
/>
<feColorMatrix type="saturate" values="0" />
</filter>
</defs>
</svg>
.textured {
filter: url(#noise);
}
No image file, no canvas, no JavaScript. The noise is computed at paint time. fractalNoise gives smoother organic textures — turbulence gives sharp, cloudy patterns.
feTurbulence — sculpting noise
The key parameters worth understanding properly rather than just tweaking randomly:
baseFrequency— the scale of the noise. Lower = larger, smoother shapes. Two values set x and y independently, which creates directional grainnumOctaves— layers of detail. More octaves = more complexity, more processing costseed— the random seed. Change this number and you get a completely different pattern with the same settings
Think of baseFrequency as zoom level and numOctaves as a detail slider:
<!-- Zoomed out — large organic shapes -->
<feTurbulence type="fractalNoise" baseFrequency="0.04" numOctaves="4" />
<!-- Zoomed in — fine-grained texture -->
<feTurbulence type="fractalNoise" baseFrequency="0.75" numOctaves="4" />
Animating feTurbulence
CSS can’t animate SVG filter attributes directly — but JavaScript can, and requestAnimationFrame makes it smooth:
const turbulence = document.querySelector('feTurbulence');
let frame = 0;
function animate() {
frame++;
const freq = 0.04 + Math.sin(frame * 0.01) * 0.02;
turbulence.setAttribute('baseFrequency', freq);
requestAnimationFrame(animate);
}
animate();
The noise field breathes. I’ve used this on hero backgrounds where the effect had to feel alive without being obviously animated — oscillating between 0.03 and 0.06 is subtle enough that most people don’t notice it’s moving.
feDisplacementMap — the warping effect
Displacement mapping moves pixels based on a source image. With feTurbulence as the source, you get that melting, liquid distortion:
Warped
scale=“18”Letterpress
scale=“4”<filter id="displace">
<feTurbulence
type="turbulence"
baseFrequency="0.015"
numOctaves="2"
result="noise"
/>
<feDisplacementMap
in="SourceGraphic"
in2="noise"
scale="18"
xChannelSelector="R"
yChannelSelector="G"
/>
</filter>
The scale attribute controls intensity. At scale="18", text warps dramatically. At scale="4", it’s a subtle organic wobble that makes type look hand-pressed. Nobody knows why it looks good, it just does.
CSS trigonometry for math-driven layouts
CSS now has sin(), cos(), tan(), atan2(), and sqrt(). A circle of elements, no JavaScript:
.dot {
position: absolute;
--angle: calc(var(--i) * (360deg / var(--total)));
top: calc(50% + sin(var(--angle)) * var(--radius));
left: calc(50% + cos(var(--angle)) * var(--radius));
transform: translate(-50%, -50%);
}
Set --i and --total on each element via inline style or a loop. No JavaScript, no canvas, no SVG — pure CSS geometry.
@property for animated generative values
@property lets you define typed CSS custom properties that the browser can interpolate. Without it, animating a custom property just snaps at every keyframe — the browser sees it as a string, not a value it can tween:
@property --angle {
syntax: '<angle>';
inherits: false;
initial-value: 0deg;
}
.rotating-gradient {
background: conic-gradient(
from var(--angle),
#884AFF,
#E93330,
#884AFF
);
animation: spin 4s linear infinite;
}
@keyframes spin {
to { --angle: 360deg; }
}
Combining everything: a generative hero
.hero {
background: linear-gradient(135deg,
oklch(0.22 0.18 265),
oklch(0.14 0.1 280)
);
filter: url(#noise-texture);
}
<filter id="noise-texture" color-interpolation-filters="sRGB">
<feTurbulence type="fractalNoise"
baseFrequency="0.65" numOctaves="3"
stitchTiles="stitch" result="noise" />
<feBlend in="SourceGraphic" in2="noise"
mode="overlay" result="blend" />
<feComposite in="blend" in2="SourceGraphic" operator="in" />
</filter>
feBlend with mode="overlay" composites the noise onto the gradient without replacing it. feComposite with operator="in" clips the result to the element’s shape — without it, the filter bleeds outside the bounds.
The same primitives combine differently for every output. Start with one parameter, change it until it does something unexpected, then figure out why.