Skip to content
Grain Overlay preview
Painted live in the browser. The committed still and loop are rendered from the SwiftUI source.

Grain Overlay

From syxUI — written for both platforms, not translated between them.

Procedural film grain to drop over any surface. No texture asset.

Backgrounds · SwiftUI · Flutter · iOS 17 · Flutter 3.27

  • background
  • grain
  • noise
  • texture
  • film
  • overlay

The actual source

GrainOverlay.swift
// Grain Overlay · syxUI · https://syxui.dev/components/grain-overlay
// Free in any project. Keep this line and credit syxUI where a person can read it.

import SwiftUI

/// Film grain you can drop over anything.
///
/// The noise is procedural — a hash per cell, no bundled texture — so it costs
/// nothing to ship and scales to any size. Use it as an overlay:
///
///     MyView().overlay { GrainOverlay().allowsHitTesting(false) }
struct GrainOverlay: View {
    /// Size of one grain cell in points. Smaller is finer and more expensive.
    var cell: CGFloat = 3
    var intensity: Double = 0.16
    /// Frames in the loop. The grain jumps between them, as real grain does.
    var steps: Int = 5
    var period: Double = 0.55
    var phase: Double? = nil

    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @State private var animated: Double = 0

    private var frameIndex: Int {
        guard !reduceMotion else { return 0 }
        let value = phase ?? animated
        return Int(value * Double(steps)) % max(steps, 1)
    }

    var body: some View {
        Canvas { context, size in
            let columns = Int(size.width / cell) + 1
            let rows = Int(size.height / cell) + 1

            for row in 0..<rows {
                for column in 0..<columns {
                    let value = hash(column, row, frameIndex)
                    // Only draw the darker half. Skipping the rest halves the
                    // fill count for the same perceived texture.
                    guard value > 0.5 else { continue }

                    let alpha = (value - 0.5) * 2 * intensity
                    let rect = CGRect(
                        x: CGFloat(column) * cell,
                        y: CGFloat(row) * cell,
                        width: cell,
                        height: cell
                    )
                    context.fill(Path(rect), with: .color(.black.opacity(alpha)))
                }
            }
        }
        .blendMode(.multiply)
        .onAppear(perform: start)
        .accessibilityHidden(true)
    }

    /// Cheap deterministic hash in 0...1. Same cell and frame always give the
    /// same value, so the loop repeats exactly.
    private func hash(_ x: Int, _ y: Int, _ frame: Int) -> Double {
        let n = Double(x) * 127.1 + Double(y) * 311.7 + Double(frame) * 74.7
        let s = sin(n) * 43758.5453
        return s - s.rounded(.down)
    }

    private func start() {
        guard phase == nil, !reduceMotion else { return }
        withAnimation(
            .linear(duration: period * Double(steps)).repeatForever(autoreverses: false)
        ) {
            animated = 1
        }
    }
}

#Preview {
    ZStack {
        LinearGradient(colors: [.orange, .pink], startPoint: .top, endPoint: .bottom)
        GrainOverlay()
    }
    .frame(width: 420, height: 300)
}
iOS 17 · No dependencies

SwiftUI note. Cost is area divided by cell squared, so 3pt cells on a full screen is roughly 100k fills. Raise `cell` to 4 before anything else if you see drops, or set `steps: 1` for static grain.

Dependencies

SwiftUI
No external dependencies

Requires iOS 17

Flutter
No external dependencies

Requires Flutter 3.27