Skip to content
Ripple Field preview
An animated render of the SwiftUI source on this page.

Ripple Field

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

Interactive

Tap anywhere and a ring of displacement runs outward through a dot lattice.

Backgrounds · SwiftUI · Flutter · iOS 17 · Flutter 3.27

  • background
  • grid
  • dots
  • ripple
  • wave
  • interactive
  • touch
  • animated

The actual source

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

import SwiftUI

/// A dot lattice where a tap sends a ring of displacement outward through the
/// field.
///
/// Ripples live in a fixed 6-slot ring buffer keyed by wall-clock time (no
/// per-tap allocation); displacement and intensity from concurrent ripples
/// sum and clamp, so a drum-roll of taps builds interference while a single
/// tap stays clean. The whole field draws as one accumulated `Path` and one
/// `context.fill` — brightening reads through added ink (bigger dots) rather
/// than per-dot opacity, which is what keeps it a single draw call.
struct RippleField: View {
    var spacing: CGFloat = 24
    var amplitude: CGFloat = 8
    var waveSpeed: CGFloat = 420
    var rippleLife: Double = 1.8
    var color: Color = Color(red: 0.184, green: 0.435, blue: 0.929)
    var background: Color = Color(red: 0.043, green: 0.051, blue: 0.078)

    /// Preview override. When set, the field renders that point of a
    /// scripted two-tap demo instead of driving its own clock — see
    /// `previews/Animate.swift`.
    var phase: Double? = nil

    /// When true and untouched for 4s, the field taps itself along a slow
    /// Lissajous path so a hero section can demo the effect unattended. Off
    /// by default — it must never animate behind real content uninvited.
    var idleDemo: Bool = false

    // Fixed shape constants — not exposed as params, they define the "one
    // ripple" silhouette rather than a knob a reader would reach for.
    private let wavelength: CGFloat = 90
    private let bandWidth: CGFloat = 60
    private let decayTau: Double = 0.55
    private let dotSize: CGFloat = 3
    private let ringSlots = 6
    private let dragEmitInterval: Double = 0.09
    private let previewPeriod: Double = 2.0
    private let previewRippleLife: Double = 1.0
    private let idleDelay: Double = 4.0

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    @State private var ripples: [Ripple] = Array(repeating: .expired, count: 6)
    @State private var cursor = 0
    @State private var isDown = false
    @State private var lastEmit: Double = -.greatestFiniteMagnitude
    @State private var isActive = false
    @State private var idleArmedAt: Double = Date().timeIntervalSinceReferenceDate
    @State private var lastSize: CGSize = .zero

    private struct Ripple {
        var point: CGPoint
        var time: Double
        static let expired = Ripple(point: .zero, time: -.greatestFiniteMagnitude)
    }

    var body: some View {
        TimelineView(.animation(paused: phase != nil || !isActive)) { timeline in
            Canvas { context, size in
                lastSize = size
                context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(background))

                let now = phase.map { $0 * previewPeriod } ?? timeline.date.timeIntervalSinceReferenceDate
                let life = phase != nil ? previewRippleLife : rippleLife
                let field = phase != nil ? scriptedRipples(size: size) : ripples
                draw(context: context, now: now, life: life, field: field, size: size)
            }
        }
        .contentShape(Rectangle())
        .gesture(
            DragGesture(minimumDistance: 0)
                .onChanged { value in
                    guard phase == nil, !reduceMotion else { return }
                    let now = Date().timeIntervalSinceReferenceDate
                    if !isDown {
                        isDown = true
                        registerRipple(at: value.location, now: now)
                    } else if now - lastEmit >= dragEmitInterval {
                        registerRipple(at: value.location, now: now)
                    }
                }
                .onEnded { _ in isDown = false }
        )
        .onContinuousHover(coordinateSpace: .local) { hoverPhase in
            guard phase == nil, !reduceMotion, !isDown else { return }
            if case .active(let location) = hoverPhase {
                let now = Date().timeIntervalSinceReferenceDate
                if now - lastEmit >= dragEmitInterval {
                    registerRipple(at: location, now: now)
                }
            }
        }
        .accessibilityHidden(true)
        .task(id: idleDemo) { await runIdleDemo() }
    }

    // MARK: - Field

    private func draw(context: GraphicsContext, now: Double, life: Double, field: [Ripple], size: CGSize) {
        guard spacing > 0 else { return }
        let columns = Int(size.width / spacing) + 2
        let rows = Int(size.height / spacing) + 2
        let carrierPeriod = Double(wavelength / max(waveSpeed, 1))

        var path = Path()
        var frameIntensity: Double = 0

        for row in 0..<rows {
            for column in 0..<columns {
                let origin = CGPoint(x: CGFloat(column) * spacing, y: CGFloat(row) * spacing)
                var dx: CGFloat = 0
                var dy: CGFloat = 0
                var intensity: Double = 0

                for ripple in field {
                    let age = now - ripple.time
                    guard age >= 0, age < life else { continue }

                    let vx = origin.x - ripple.point.x
                    let vy = origin.y - ripple.point.y
                    let r = (vx * vx + vy * vy).squareRoot()
                    let front = waveSpeed * CGFloat(age)
                    let bandDelta = r - front
                    // Annulus early-out: only dots within 3 band-widths of the
                    // travelling front do the transcendental work.
                    guard abs(bandDelta) < bandWidth * 3 else { continue }

                    let carrier = sin(2 * .pi * (Double(r / wavelength) - age / carrierPeriod))
                    let gaussTerm = Double(bandDelta / bandWidth)
                    let envelope = exp(-age / decayTau) * exp(-gaussTerm * gaussTerm)
                    intensity += envelope
                    if r > 0.5 {
                        let push = amplitude * CGFloat(carrier * envelope) / r
                        dx += vx * push
                        dy += vy * push
                    }
                }

                intensity = min(intensity, 1)
                frameIntensity = max(frameIntensity, intensity)

                let point = CGPoint(x: origin.x + dx, y: origin.y + dy)
                let radius = dotSize * (1 + 0.7 * intensity) / 2
                path.addEllipse(in: CGRect(x: point.x - radius, y: point.y - radius, width: radius * 2, height: radius * 2))
            }
        }

        let opacity = 0.45 + 0.55 * frameIntensity
        context.fill(path, with: .color(color.opacity(opacity)))
    }

    private func scriptedRipples(size: CGSize) -> [Ripple] {
        // Mechanism A (§0.5): two scripted taps. `previewRippleLife` is
        // deliberately shorter than the real `rippleLife` default so both
        // are fully decayed before the 28-frame loop wraps at phase 1.
        [
            (t0: 0.03, x: 0.33, y: 0.40),
            (t0: 0.44, x: 0.70, y: 0.63),
        ].map { tap in
            Ripple(
                point: CGPoint(x: tap.x * size.width, y: tap.y * size.height),
                time: tap.t0 * previewPeriod
            )
        }
    }

    // MARK: - Interaction

    private func registerRipple(at point: CGPoint, now: Double) {
        ripples[cursor % ringSlots] = Ripple(point: point, time: now)
        cursor += 1
        lastEmit = now
        isActive = true
        idleArmedAt = now
        scheduleSettleCheck(after: rippleLife + 0.1)
    }

    private func scheduleSettleCheck(after delay: Double) {
        Task {
            try? await Task.sleep(nanoseconds: UInt64(max(delay, 0) * 1_000_000_000))
            await MainActor.run {
                let now = Date().timeIntervalSinceReferenceDate
                let stillLive = ripples.contains { now - $0.time < rippleLife }
                if !stillLive && !isDown { isActive = false }
            }
        }
    }

    private func runIdleDemo() async {
        guard idleDemo, phase == nil, !reduceMotion else { return }
        while !Task.isCancelled {
            try? await Task.sleep(nanoseconds: 500_000_000)
            guard idleDemo, !isDown else { continue }
            let now = Date().timeIntervalSinceReferenceDate
            guard now - idleArmedAt >= idleDelay, lastSize != .zero else { continue }
            let t = (now - idleArmedAt - idleDelay) / 3.0
            let unit = CGPoint(x: 0.5 + 0.30 * sin(2 * .pi * t), y: 0.5 + 0.22 * sin(4 * .pi * t + 0.7))
            registerRipple(at: CGPoint(x: unit.x * lastSize.width, y: unit.y * lastSize.height), now: now)
        }
    }
}

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

SwiftUI note. Cost is dots x live ripples, but the annulus early-out (|r - front| < 3 x bandWidth) means only dots actually inside a travelling band do the sin/exp work — worst case 6 concurrent ripples is ~3,570 evaluations, typical is under 600. Raise `spacing` first if you see drops. Touch-down, drag, and `.onContinuousHover` all write into the same 6-slot ripple ring buffer, so a Mac cursor wakes the field exactly like a finger. With nothing touching it the demo comes from `phase`: `previews/Animate.swift` scripts two taps that fully decay before the loop wraps. Set `idleDemo` to have the field tap itself along a Lissajous path after 4s of real idle time, for unattended hero use.

Dependencies

SwiftUI
No external dependencies

Requires iOS 17

Flutter
No external dependencies

Requires Flutter 3.27