EmpireUI
Get Pro
← Blog8 min read#css texture#grain effect#noise

Grain & Noise Texture in CSS: SVG Filters, Canvas and Pure CSS

Add realistic grain and noise texture to your UI with SVG filters, Canvas API, and pure CSS — no images, no extra HTTP requests, full control.

Abstract grainy noise texture on dark background for UI design

Why Grain Texture Is Having a Moment

Flat design peaked around 2014 and the backlash has been slow but steady. Now in 2026, noise and grain are all over high-end product UIs — hero sections, frosted cards, dark-mode backgrounds. You've seen it. That subtle gritty layer that makes a surface feel physical instead of plasticky.

The appeal isn't nostalgia for old monitors. It's contrast. Smooth gradients look digital and sterile. Add 3-5% opacity grain on top and suddenly the same surface reads as tactile. Your eye reads depth before your brain does.

Honestly, the technique is older than most devs working today — film grain has been a deliberate artistic choice since photography existed. We're just finally doing it properly in CSS instead of shipping a 200kb PNG as a background-image tile.

Worth noting: grain pairs especially well with glassmorphism components where the frosted glass metaphor benefits from physical texture, and with vaporwave aesthetics where analog warmth is the whole vibe.

The SVG feTurbulence Filter: Your Best Option

SVG filters are the cleanest way to generate noise in the browser. No JavaScript, no Canvas, no extra requests. The feTurbulence primitive generates Perlin or fractal noise directly on the GPU. You inline it, reference it with a CSS filter or url(), done.

Here's the core pattern you'll use for 90% of grain effects:

<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" style="position:absolute">
  <filter id="grain">
    <feTurbulence
      type="fractalNoise"
      baseFrequency="0.65"
      numOctaves="3"
      stitchTiles="stitch"
    />
    <feColorMatrix type="saturate" values="0" />
    <feBlend in="SourceGraphic" mode="overlay" />
  </filter>
</svg>

<style>
.grainy {
  filter: url(#grain);
  /* Or as a pseudo-element overlay */
}

.grainy-overlay::after {
  content: '';
  position: absolute;
  inset: 0;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.04'/%3E%3C/svg+xml");
  pointer-events: none;
  opacity: 0.06;
  mix-blend-mode: overlay;
}
</style>

The baseFrequency is your main dial. Lower values like 0.3 give you large, smooth blobs — almost a cloud texture. Push it to 0.8 and you get fine film grain. Most UI use cases land between 0.6 and 0.7. The numOctaves adds detail layers; 3 is usually enough, and going past 5 gives diminishing returns with real GPU cost.

One more thing — stitchTiles="stitch" tells the renderer to tile the noise seamlessly. You almost always want this on. Without it, you'll see hard edges at every repetition of the background tile, which looks terrible on larger surfaces.

Encoding SVG as a data URI (No External File Needed)

Inline the SVG as a data URI in your CSS background-image and you've got a completely self-contained grain overlay. No extra network request, no HTML pollution, nothing to maintain in your asset pipeline.

.card {
  position: relative;
  background: linear-gradient(135deg, #1a1a2e, #16213e);
  border-radius: 16px;
  overflow: hidden;
}

.card::before {
  content: '';
  position: absolute;
  inset: 0;
  /* SVG noise encoded as data URI */
  background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
  background-size: 200px 200px;
  opacity: 0.04;
  mix-blend-mode: screen;
  pointer-events: none;
  border-radius: inherit;
  z-index: 1;
}

The mix-blend-mode matters a lot here. overlay preserves contrast and gives a film-like look. screen lightens, good for dark UIs. multiply darkens, works on light backgrounds. Try soft-light for the subtlest result — it barely affects the underlying color but adds that physical grain feel at 200px tile size.

In practice, opacity between 0.03 and 0.08 is the sweet spot for most UIs. Go higher and it starts to look dirty rather than intentional. Go lower and you've wasted your time — nobody will see it. You want the grain to register subconsciously, not consciously.

Canvas API Grain: When You Need Dynamic or Animated Noise

SVG feTurbulence is static — same grain every render. If you want the grain to animate (think the look of an old TV broadcast, or a cinematic film-grain intro), you need Canvas. It's more JavaScript but you get full control over every pixel.

import { useEffect, useRef } from 'react'

export function GrainCanvas({ opacity = 0.05, speed = 0.5 }) {
  const canvasRef = useRef(null)
  const frameRef = useRef(null)

  useEffect(() => {
    const canvas = canvasRef.current
    const ctx = canvas.getContext('2d')

    const resize = () => {
      canvas.width = canvas.offsetWidth
      canvas.height = canvas.offsetHeight
    }
    resize()
    window.addEventListener('resize', resize)

    const drawGrain = () => {
      const { width, height } = canvas
      const imageData = ctx.createImageData(width, height)
      const data = imageData.data

      for (let i = 0; i < data.length; i += 4) {
        const value = (Math.random() * 255) | 0
        data[i] = value
        data[i + 1] = value
        data[i + 2] = value
        data[i + 3] = (opacity * 255) | 0
      }

      ctx.putImageData(imageData, 0, 0)
      frameRef.current = requestAnimationFrame(drawGrain)
    }

    drawGrain()

    return () => {
      cancelAnimationFrame(frameRef.current)
      window.removeEventListener('resize', resize)
    }
  }, [opacity])

  return (
    <canvas
      ref={canvasRef}
      style={{
        position: 'absolute',
        inset: 0,
        width: '100%',
        height: '100%',
        pointerEvents: 'none',
        mixBlendMode: 'overlay',
        zIndex: 1,
      }}
    />
  )
}

This draws a new random noise pattern every animation frame — full cinematic grain. It's expensive though. On a 1920×1080 viewport you're touching ~8 million array indices per frame. Use it for hero sections and modals, not for 40 card components on the same page.

Quick aside: if you want to throttle it for perf, replace requestAnimationFrame(drawGrain) with setTimeout(() => requestAnimationFrame(drawGrain), 1000 / 24) to cap at 24fps — that's actually more film-like anyway.

Pure CSS Noise: Tricks Without SVG or Canvas

Can you fake grain with nothing but CSS? Sort of. You can't generate true random noise — CSS doesn't have a random primitive. But you can create structured noise patterns using gradients and the right combination of background layers.

.pseudo-grain {
  background-image:
    /* Layer 1: fine dot pattern */
    radial-gradient(circle, rgba(255,255,255,0.08) 1px, transparent 1px),
    /* Layer 2: offset dot pattern for texture */
    radial-gradient(circle, rgba(255,255,255,0.04) 1px, transparent 1px),
    /* Your actual background */
    linear-gradient(135deg, #0f0f23, #1a1a3e);
  background-size: 4px 4px, 6px 6px, 100% 100%;
  background-position: 0 0, 2px 2px, 0 0;
}

Look, this isn't real noise. It's a repeating dot grid. Up close it's obviously geometric. But at the right dot size (3-6px) and low opacity, it creates a plausible texture hit. It works surprisingly well for neumorphism cards where the surface needs physical presence without the weight of a full SVG filter.

The CSS-only approach also has zero render cost compared to Canvas, and slightly less than SVG filters. If you're building for low-powered devices or need to grain 50+ elements on a page, this is your escape hatch. Just don't expect it to fool anyone looking closely.

That said, browser support for mixing multiple background-image layers has been solid since Chrome 26 / Firefox 16, so you're not taking any compatibility risk here.

Performance, Compositing and the GPU Layer Trap

Here's the thing nobody tells you about CSS filters: filter: url(#grain) on a large element forces the browser to composite that element on its own GPU layer. That's usually fine — but if you apply it to your root layout element, you've just moved your entire page paint to a separate layer and potentially broken position: fixed children.

The safer pattern is always the ::before / ::after pseudo-element overlay approach. The noise lives on its own painted layer, your layout element stays in normal flow, and position: fixed descendants don't get trapped inside a new stacking context.

Also watch out for will-change: filter as a 'performance optimization' — it creates a new compositing layer unconditionally, which costs GPU memory even when the animation isn't running. Only reach for it when you're actively animating the filter value, not for static grain.

If you're applying grain across multiple components, consider adding it once at the page level via a full-screen fixed overlay. One compositing layer beats 20. Browse the Empire UI component library — several components already handle this pattern correctly with isolated overlay pseudo-elements so you don't have to think about it.

Combining Grain With Other CSS Styles

Grain doesn't live alone — it multiplies its impact when combined with the right base styles. A flat #1a1a1a background with grain looks boring. A radial gradient dark background with grain looks like you shot it on 35mm.

The glassmorphism generator and gradient generator are solid starting points for building the base layer that grain will sit on top of. The workflow is: design the color layer first, get it right, then add grain as the final pass. Never the other way around.

For cyberpunk UIs, grain combined with a scanline overlay (another ::after with repeating horizontal lines at 2px intervals) gets you close to a CRT monitor effect purely in CSS. For aurora aesthetics, light grain at 0.03 opacity on a soft gradient reads as atmospheric haze. The texture is doing emotional work, not just visual work.

One more thing — test your grain on both retina and non-retina screens before shipping. A baseFrequency of 0.65 that looks perfect at 1x DPI will look coarser than you expect at 2x because the noise tiles at physical pixels, not CSS pixels. You might need to bump the frequency to 0.8 or scale the tile smaller for HiDPI displays.

FAQ

What's the difference between feTurbulence type='turbulence' and type='fractalNoise'?

fractalNoise produces smoother, more organic grain — the kind you want for UI texture. turbulence creates sharper, more chaotic patterns with stronger contrast. For film grain effects, stick with fractalNoise.

Does SVG feTurbulence grain affect performance?

On its own, a single SVG filter on a reasonably sized element is fine. Problems start when you apply it to many elements or use it inside a scroll container that needs frequent repaints. Use the pseudo-element overlay pattern to keep impact contained.

Can I animate grain texture with CSS only?

You can animate the SVG filter's baseFrequency via CSS custom properties and @property, but browser support for animating SVG filter attributes via CSS is still patchy as of 2026. Canvas is the reliable path for animated grain.

What opacity value should I use for grain overlays?

Between 0.03 and 0.08 for most UIs — enough to register as texture without looking dirty. Dark UIs with light grain usually sit around 0.05; light UIs with dark grain often need to go lower, around 0.03.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Noise + Gradient in CSS: The Texture Combo Designers Love in 2026CSS Noise Texture Effect: Grain, Grit and Analog FeelCSS border-radius Patterns: From Rounded to Blob ShapesFree Glassmorphism CSS Generator (Copy-Paste Tailwind)