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.

feTurbulence — fractalNoise vs turbulence
fractalNoise
turbulence
<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 grain
  • numOctaves — layers of detail. More octaves = more complexity, more processing cost
  • seed — 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:

baseFrequency — 0.04 vs 0.75
0.04 — large shapes
0.75 — fine grain
<!-- 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:

Breathing noise — animated baseFrequency
oscillating baseFrequency
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:

feDisplacementMap — warped text

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:

CSS trig — circle of dots
.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 — rotating conic gradient
@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

Noise + gradient — no image file
noise + gradient
.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.