effects#background#texture

Noise Gradient

Animated film grain texture layered over smooth gradients for a premium aesthetic

Implementation Guide
# Noise Gradient Effect

> Animated film grain/noise texture layered over smooth gradients. Adds texture and depth to flat backgrounds - very trendy in modern design.

## Quick Start

```tsx
import NoiseGradient from '@/components/examples/effects/noise-gradient';

export default function Hero() {
  return (
    <div className="relative h-screen">
      <NoiseGradient colors={['#0a0a0a', '#1a1a3e']} />
      <div className="relative z-10">{/* Your content goes here */}</div>
    </div>
  );
}
```

## Props

| Prop          | Type             | Default                | Description                                   |
| ------------- | ---------------- | ---------------------- | --------------------------------------------- |
| colors        | [string, string] | ['#0a0a0a', '#1a1a3e'] | Gradient start and end colors                 |
| noiseOpacity  | number           | 0.15                   | Noise layer opacity (0 - 1)                   |
| noiseScale    | number           | 1                      | Grain size multiplier (1 = fine, 2+ = coarse) |
| animated      | boolean          | true                   | Whether noise animates/shifts                 |
| gradientAngle | number           | 135                    | Gradient direction in degrees (0 - 360)       |

## Full Implementation

```tsx
'use client';

import { useEffect, useRef, useCallback } from 'react';

interface NoiseGradientProps {
  colors?: [string, string];
  noiseOpacity?: number;
  noiseScale?: number;
  animated?: boolean;
  gradientAngle?: number;
}

export default function NoiseGradient({
  colors = ['#0a0a0a', '#1a1a3e'],
  noiseOpacity = 0.15,
  noiseScale = 1,
  animated = true,
  gradientAngle = 135,
}: NoiseGradientProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const animationRef = useRef<number>(0);
  const noiseDataRef = useRef<ImageData | null>(null);
  const offsetRef = useRef({ x: 0, y: 0 });

  const generateNoisePattern = useCallback(
    (ctx: CanvasRenderingContext2D, width: number, height: number): ImageData => {
      const scaledWidth = Math.ceil(width / noiseScale);
      const scaledHeight = Math.ceil(height / noiseScale);
      const imageData = ctx.createImageData(scaledWidth, scaledHeight);
      const data = imageData.data;

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

      return imageData;
    },
    [noiseScale]
  );

  const drawNoise = useCallback(
    (
      ctx: CanvasRenderingContext2D,
      noiseCanvas: HTMLCanvasElement,
      width: number,
      height: number,
      offsetX: number,
      offsetY: number
    ) => {
      ctx.save();
      ctx.globalAlpha = noiseOpacity;
      ctx.globalCompositeOperation = 'overlay';

      // Draw noise with offset for animation effect
      const patternOffsetX = Math.floor(offsetX) % noiseCanvas.width;
      const patternOffsetY = Math.floor(offsetY) % noiseCanvas.height;

      // Tile the noise pattern across the canvas
      for (let x = -patternOffsetX; x < width; x += noiseCanvas.width) {
        for (let y = -patternOffsetY; y < height; y += noiseCanvas.height) {
          ctx.drawImage(
            noiseCanvas,
            x,
            y,
            noiseCanvas.width * noiseScale,
            noiseCanvas.height * noiseScale
          );
        }
      }

      ctx.restore();
    },
    [noiseOpacity, noiseScale]
  );

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;

    const ctx = canvas.getContext('2d', { alpha: false });
    if (!ctx) return;

    // Create offscreen canvas for noise pattern
    const noiseCanvas = document.createElement('canvas');
    const noiseCtx = noiseCanvas.getContext('2d');
    if (!noiseCtx) return;

    let prefersReducedMotion = false;

    const resizeCanvas = () => {
      const rect = canvas.getBoundingClientRect();
      const dpr = window.devicePixelRatio || 1;
      canvas.width = rect.width * dpr;
      canvas.height = rect.height * dpr;
      ctx.scale(dpr, dpr);

      // Generate noise pattern sized for the canvas
      const noiseSize = Math.max(256, Math.ceil(Math.max(rect.width, rect.height) / noiseScale));
      noiseCanvas.width = noiseSize;
      noiseCanvas.height = noiseSize;

      noiseDataRef.current = generateNoisePattern(noiseCtx, noiseSize, noiseSize);
      noiseCtx.putImageData(noiseDataRef.current, 0, 0);
    };

    const drawGradient = (width: number, height: number) => {
      // Convert angle to radians and calculate gradient coordinates
      const angleRad = (gradientAngle * Math.PI) / 180;
      const diagonal = Math.sqrt(width * width + height * height);
      const centerX = width / 2;
      const centerY = height / 2;

      const gradientStartX = centerX - (Math.cos(angleRad) * diagonal) / 2;
      const gradientStartY = centerY - (Math.sin(angleRad) * diagonal) / 2;
      const gradientEndX = centerX + (Math.cos(angleRad) * diagonal) / 2;
      const gradientEndY = centerY + (Math.sin(angleRad) * diagonal) / 2;

      const gradient = ctx.createLinearGradient(
        gradientStartX,
        gradientStartY,
        gradientEndX,
        gradientEndY
      );
      gradient.addColorStop(0, colors[0]);
      gradient.addColorStop(1, colors[1]);

      ctx.fillStyle = gradient;
      ctx.fillRect(0, 0, width, height);
    };

    const render = () => {
      const rect = canvas.getBoundingClientRect();
      const width = rect.width;
      const height = rect.height;

      // Draw gradient background
      drawGradient(width, height);

      // Draw noise overlay
      drawNoise(ctx, noiseCanvas, width, height, offsetRef.current.x, offsetRef.current.y);
    };

    const animate = () => {
      if (prefersReducedMotion) {
        render();
        return;
      }

      // Slowly drift the noise pattern
      offsetRef.current.x += 0.3;
      offsetRef.current.y += 0.2;

      render();
      animationRef.current = requestAnimationFrame(animate);
    };

    const regenerateNoise = () => {
      if (!noiseCtx || prefersReducedMotion) return;

      noiseDataRef.current = generateNoisePattern(noiseCtx, noiseCanvas.width, noiseCanvas.height);
      noiseCtx.putImageData(noiseDataRef.current, 0, 0);
    };

    // Check for reduced motion preference
    const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
    prefersReducedMotion = motionQuery.matches;

    const handleMotionChange = (event: MediaQueryListEvent) => {
      prefersReducedMotion = event.matches;
      if (prefersReducedMotion) {
        cancelAnimationFrame(animationRef.current);
        render();
      } else if (animated) {
        animate();
      }
    };

    motionQuery.addEventListener('change', handleMotionChange);

    resizeCanvas();

    if (animated && !prefersReducedMotion) {
      animate();

      // Periodically regenerate noise for more organic feel
      const noiseInterval = setInterval(regenerateNoise, 100);

      const handleResize = () => {
        resizeCanvas();
      };

      window.addEventListener('resize', handleResize);

      return () => {
        cancelAnimationFrame(animationRef.current);
        clearInterval(noiseInterval);
        window.removeEventListener('resize', handleResize);
        motionQuery.removeEventListener('change', handleMotionChange);
      };
    } else {
      render();

      const handleResize = () => {
        resizeCanvas();
        render();
      };

      window.addEventListener('resize', handleResize);

      return () => {
        window.removeEventListener('resize', handleResize);
        motionQuery.removeEventListener('change', handleMotionChange);
      };
    }
  }, [colors, noiseOpacity, noiseScale, animated, gradientAngle, generateNoisePattern, drawNoise]);

  return (
    <canvas
      ref={canvasRef}
      className="h-full w-full"
      style={{
        display: 'block',
      }}
    />
  );
}
```

## Customization

### Dark Moody Gradient

Classic dark gradient with subtle film grain:

```tsx
<NoiseGradient colors={['#0a0a0a', '#1a1a3e']} noiseOpacity={0.12} gradientAngle={135} />
```

### Warm Sunset

Warm tones with visible grain texture:

```tsx
<NoiseGradient colors={['#1a0a0a', '#3e2a1a']} noiseOpacity={0.2} gradientAngle={180} />
```

### Cool Ocean

Blue-green gradient with fine noise:

```tsx
<NoiseGradient colors={['#0a1a1a', '#1a3e3e']} noiseOpacity={0.1} noiseScale={0.5} />
```

### High Contrast Grain

Bold gradient with heavy film grain effect:

```tsx
<NoiseGradient colors={['#000000', '#2a2a4a']} noiseOpacity={0.25} noiseScale={2} />
```

### Static Background

Non-animated version for performance-sensitive contexts:

```tsx
<NoiseGradient colors={['#0f0f1a', '#1a1a2e']} animated={false} noiseOpacity={0.15} />
```

### Horizontal Gradient

Change gradient direction:

```tsx
<NoiseGradient colors={['#1a0a2e', '#0a1a1a']} gradientAngle={90} />
```

### Vertical Gradient

Top to bottom gradient:

```tsx
<NoiseGradient colors={['#0a0a1a', '#1a1a3e']} gradientAngle={180} />
```

### Brand Colors Example

Using brand colors with noise overlay:

```tsx
<NoiseGradient colors={['#007AFF', '#5856D6']} noiseOpacity={0.08} gradientAngle={135} />
```

## Performance Considerations

- **Canvas-based rendering** - Uses hardware-accelerated 2D canvas for smooth performance
- **Offscreen noise generation** - Noise pattern is generated on a separate canvas and reused
- **Noise regeneration interval** - Noise updates every 100ms for organic feel without overwhelming the GPU
- **Resize handling** - Canvas properly resizes with device pixel ratio support for crisp rendering on retina displays
- **Animation frame management** - Uses `requestAnimationFrame` for smooth 60fps animation
- **Static mode available** - Set `animated={false}` for static backgrounds to eliminate animation overhead
- **Memory efficient** - Reuses ImageData buffers rather than creating new ones each frame

### Mobile Optimization

For mobile devices, consider these adjustments:

```tsx
// Reduced animation intensity for mobile
<NoiseGradient
  noiseOpacity={0.1}
  noiseScale={2} // Coarser grain = fewer pixels to process
  animated={true}
/>
```

## Accessibility

- **Respects `prefers-reduced-motion`** - Animation stops completely when user prefers reduced motion, displaying static noise instead
- **Listens for preference changes** - Dynamically responds if user toggles motion preferences during session
- **Purely decorative** - Effect is visual enhancement only; ensure content above has proper contrast
- **Content layering** - Use `position: relative; z-index: 10` on content to ensure it appears above the canvas
- **Contrast considerations** - Dark gradients work best; ensure text overlays have sufficient contrast ratios

### Reduced Motion Example

The component automatically detects and respects user preferences:

```tsx
// This will show static noise if user has prefers-reduced-motion enabled
<NoiseGradient animated={true} />
```

## Browser Support

- **Canvas 2D API** - All modern browsers (Chrome, Firefox, Safari, Edge)
- **requestAnimationFrame** - IE10+ and all modern browsers
- **ImageData manipulation** - All browsers with Canvas support
- **matchMedia** - IE10+ for `prefers-reduced-motion` detection
- **devicePixelRatio** - Full support for retina/HiDPI displays

### Fallback Considerations

For browsers without Canvas support (extremely rare), consider adding a CSS gradient fallback:

```tsx
<div className="relative h-full w-full">
  {/* CSS fallback */}
  <div
    className="absolute inset-0"
    style={{
      background: 'linear-gradient(135deg, #0a0a0a 0%, #1a1a3e 100%)',
    }}
  />
  {/* Canvas noise overlay */}
  <NoiseGradient colors={['#0a0a0a', '#1a1a3e']} />
</div>
```