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

Ripple Grid

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

A dot lattice with a ripple travelling outward, brightening dots as it passes.

Backgrounds · SwiftUI · Flutter · iOS 17 · Flutter 3.27

  • background
  • grid
  • dots
  • ripple
  • wave
  • animated
  • pattern

The actual source

RippleGrid.swift
// Ripple Grid · syxUI · https://syxui.dev/components/ripple-grid
// Free in any project. Keep this line and credit syxUI where a person can read it.

import SwiftUI

/// A dot lattice with a ripple travelling outward through it.
///
/// Dots scale and brighten as the wave passes, then settle. One `Canvas` pass
/// draws the whole field, so density is nearly free.
struct RippleGrid: View {
    var spacing: CGFloat = 26
    var dotSize: CGFloat = 3.2
    var color: Color = Color(white: 0.30)
    var background: Color = Color(white: 0.99)
    /// Rings visible at once. Higher reads busier.
    var waves: Double = 2.2
    var period: Double = 4.5
    var phase: Double? = nil

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

    private var current: Double { phase ?? animated }

    var body: some View {
        Canvas { context, size in
            context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(background))

            let centre = CGPoint(x: size.width / 2, y: size.height / 2)
            let maxDistance = hypot(size.width, size.height) / 2
            let columns = Int(size.width / spacing) + 2
            let rows = Int(size.height / spacing) + 2

            for row in 0..<rows {
                for column in 0..<columns {
                    let point = CGPoint(
                        x: CGFloat(column) * spacing,
                        y: CGFloat(row) * spacing
                    )
                    let distance = hypot(point.x - centre.x, point.y - centre.y) / maxDistance

                    // Ring position minus the dot's distance from centre gives
                    // each dot its own place in the wave.
                    let wave = sin((current - distance) * waves * 2 * .pi)
                    let intensity = (wave + 1) / 2
                    let scale = 0.55 + 0.85 * intensity
                    let opacity = (0.18 + 0.62 * intensity) * (1 - distance * 0.55)
                    let radius = dotSize * scale

                    let rect = CGRect(
                        x: point.x - radius / 2,
                        y: point.y - radius / 2,
                        width: radius,
                        height: radius
                    )
                    context.fill(Path(ellipseIn: rect), with: .color(color.opacity(max(opacity, 0))))
                }
            }
        }
        .onAppear(perform: start)
        .accessibilityHidden(true)
    }

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

#Preview {
    RippleGrid()
        .frame(width: 420, height: 300)
}
iOS 17 · No dependencies

SwiftUI note. Redraws every frame, so keep the spacing at 20pt or more on a full screen. Raise `spacing` before you raise anything else if you see frame drops.

Dependencies

SwiftUI
No external dependencies

Requires iOS 17

Flutter
No external dependencies

Requires Flutter 3.27