
Ripple Field
From syxUI — written for both platforms, not translated between them.
InteractiveTap 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
// 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)
}
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
- Flutter
- No external dependencies
Requires iOS 17
Requires Flutter 3.27


