Skip to content
Tile Wave preview
An animated render of the SwiftUI source on this page.

Tile Wave

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

Interactive

A tile grid where every tap flips tiles outward in a wave, like falling dominoes.

Backgrounds · SwiftUI · Flutter · iOS 17 · Flutter 3.27

  • background
  • grid
  • tiles
  • wave
  • flip
  • interactive
  • touch

The actual source

TileWave.swift
// Tile Wave · syxUI · https://syxui.dev/components/tile-wave
// Free in any project. Keep this line and credit syxUI where a person can read it.

import SwiftUI

/// A tile grid where every tap flips tiles outward in a wave, like falling
/// dominoes.
///
/// Taps live in a fixed 4-slot ring buffer; each tile computes its own delay
/// from `distance(tile, tap) / waveSpeed` under the chosen `metric`, so the
/// wavefront shape — circular, square, or diamond — falls out of one
/// distance function. Overlapping waves take the **max** of their envelopes
/// per tile, so they never double-flip.
struct TileWave: View {
    var tileSize: CGFloat = 34
    var gap: CGFloat = 2
    var waveSpeed: CGFloat = 900
    var flipDuration: Double = 0.45
    var tileColor: Color = Color(red: 0.122, green: 0.435, blue: 0.922)
    /// `"euclidean"` (circular), `"chebyshev"` (square), or `"manhattan"` (diamond).
    var metric: String = "euclidean"

    /// Preview override. When set, one scripted tap plays instead of the
    /// field 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

    private let tapLife: Double = 1.6
    private let restOpacity: Double = 0.35
    private let ringSlots = 4
    private let dragEmitInterval: Double = 0.14
    private let previewPeriod: Double = 2.0
    private let idleDelay: Double = 4.0

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    @State private var taps: [Tap] = Array(repeating: .expired, count: 4)
    @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 Tap {
        var point: CGPoint
        var time: Double
        static let expired = Tap(point: .zero, time: -.greatestFiniteMagnitude)
    }

    var body: some View {
        TimelineView(.animation(paused: phase != nil || !isActive)) { timeline in
            Canvas { context, size in
                lastSize = size
                let now = phase.map { $0 * previewPeriod } ?? timeline.date.timeIntervalSinceReferenceDate
                let field = phase != nil ? [scriptedTap(size: size)] : taps
                draw(context: context, size: size, now: now, field: field)
            }
        }
        .contentShape(Rectangle())
        .gesture(
            DragGesture(minimumDistance: 0)
                .onChanged { value in
                    guard phase == nil, !reduceMotion else { return }
                    let now = Date().timeIntervalSinceReferenceDate
                    if !isDown {
                        isDown = true
                        registerTap(at: value.location, now: now)
                    } else if now - lastEmit >= dragEmitInterval {
                        registerTap(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 {
                    registerTap(at: location, now: now)
                }
            }
        }
        .accessibilityHidden(true)
        .task(id: idleDemo) { await runIdleDemo() }
    }

    // MARK: - Field

    private func draw(context: GraphicsContext, size: CGSize, now: Double, field: [Tap]) {
        let pitch = tileSize + gap
        guard pitch > 0 else { return }
        let columns = Int(size.width / pitch) + 2
        let rows = Int(size.height / pitch) + 2
        let cornerRadius = tileSize * 0.2

        for row in 0..<rows {
            for column in 0..<columns {
                let center = CGPoint(
                    x: CGFloat(column) * pitch + pitch / 2,
                    y: CGFloat(row) * pitch + pitch / 2
                )

                var envelope: Double = 0
                for tap in field {
                    let age = now - tap.time
                    guard age >= 0, age < tapLife else { continue }
                    let distance = metricDistance(center, tap.point)
                    let delay = Double(distance / waveSpeed)
                    let localAge = age - delay
                    guard localAge >= 0, localAge < flipDuration else { continue }
                    let u = localAge / flipDuration
                    envelope = max(envelope, sin(.pi * u))
                }

                let widthScale = 1 - 0.85 * envelope
                let opacity = restOpacity + (1 - restOpacity) * envelope
                let width = tileSize * CGFloat(widthScale)
                let rect = CGRect(
                    x: center.x - width / 2, y: center.y - tileSize / 2,
                    width: width, height: tileSize
                )
                context.fill(
                    Path(roundedRect: rect, cornerRadius: cornerRadius),
                    with: .color(tileColor.opacity(opacity))
                )
            }
        }
    }

    private func metricDistance(_ a: CGPoint, _ b: CGPoint) -> CGFloat {
        let dx = abs(a.x - b.x)
        let dy = abs(a.y - b.y)
        switch metric {
        case "chebyshev": return max(dx, dy)
        case "manhattan": return dx + dy
        default: return hypot(dx, dy)
        }
    }

    private func scriptedTap(size: CGSize) -> Tap {
        // Mechanism A (§0.5): one scripted tap at phase 0.05. `flipDuration`
        // plus the crossing time comfortably fits under the 2.0s loop.
        Tap(point: CGPoint(x: 0.42 * size.width, y: 0.38 * size.height), time: 0.05 * previewPeriod)
    }

    // MARK: - Interaction

    private func registerTap(at point: CGPoint, now: Double) {
        taps[cursor % ringSlots] = Tap(point: point, time: now)
        cursor += 1
        lastEmit = now
        isActive = true
        idleArmedAt = now
        scheduleSettleCheck(after: tapLife + 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 = taps.contains { now - $0.time < tapLife }
                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))
            registerTap(at: CGPoint(x: unit.x * lastSize.width, y: unit.y * lastSize.height), now: now)
        }
    }
}

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

SwiftUI note. Tiles = area / (tileSize + gap)^2; at the default 34pt on 390x844 that is 11x24 = 264 rounded-rect fills — trivially cheap, and the safest effect in this batch. Rounded rects cost noticeably more than circles, so below ~20pt tiles on a full screen (~1,700 tiles) switch to plain rects instead. Touch-down, drag, and `.onContinuousHover` all feed the same 4-slot tap ring buffer, so a Mac cursor paints a moving front exactly like a finger. With nothing touching it, `previews/Animate.swift` scripts one tap via `phase`; set `idleDemo` to have the field tap itself on a Lissajous path after 4s of real idle time.

Dependencies

SwiftUI
No external dependencies

Requires iOS 17

Flutter
No external dependencies

Requires Flutter 3.27