Skip to content
Jelly Blob preview
An animated render of the SwiftUI source on this page.

Jelly Blob

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

Interactive

One big soft shape that wobbles like set jelly wherever you poke it.

Backgrounds · SwiftUI · Flutter · iOS 17 · Flutter 3.27

  • background
  • blob
  • jelly
  • wobble
  • organic
  • interactive
  • touch

The actual source

JellyBlob.swift
// Jelly Blob · syxUI · https://syxui.dev/components/jelly-blob
// Free in any project. Keep this line and credit syxUI where a person can read it.

import SwiftUI

/// One big soft shape that wobbles like set jelly wherever you poke it.
///
/// The wobble is a bank of damped angular oscillators — one per radial mode
/// `n = 2...modes+1` — each a real second-order simulation (position *and*
/// velocity), not a keyframe curve: a touch adds a velocity kick, and the
/// mode rings down on its own afterwards. Modes are stored as two coupled
/// real oscillators per `n` (a cos-basis and a sin-basis component) so kicks
/// from any angle compose additively with no per-tap buffer, and higher
/// modes are tuned to decay faster than the fundamental — that's what reads
/// as jelly rather than a cartoon boing.
///
/// `JellySim` is a plain, non-observed class held in `@State` and mutated
/// from inside the `Canvas` closure, per §0.2 of the backgrounds contract.
struct JellyBlob: View {
    var radius: CGFloat = 150
    var wobble: Double = 0.16
    var decay: Double = 0.75
    /// More modes reads stiffer.
    var modes: Int = 4
    var fill: [Color] = [
        Color(red: 1.0, green: 0.478, blue: 0.349),
        Color(red: 1.0, green: 0.239, blue: 0.506),
    ]
    var background: Color = Color(red: 0.071, green: 0.024, blue: 0.059)
    /// Preview override. When set, the wobble is evaluated as a closed-form
    /// impulse response instead of stepping the live oscillator bank — see
    /// `previewDeformation`.
    var phase: Double? = nil

    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @State private var animated: Double = 0
    @State private var sim = JellySim()
    @State private var heldTouch: CGPoint? = nil
    @State private var touchDownAt: CGPoint? = nil

    private var current: Double { phase ?? animated }
    private let sampleCount = 120
    // The breathing period this component describes (1.0 -> 1.03 over 5s) is
    // continuous ambient motion, not something that settles — `current`
    // cycles forever in the live case and samples one such cycle in preview.
    private let breathePeriod: Double = 5.0
    // Preview-only: the scripted poke's clock, independent of `breathePeriod`
    // so the closed-form ring-down has room to fully settle before the loop
    // wraps (see `previewDeformation`).
    private let previewPeriodSeconds: Double = 3.0
    private let previewPokeAt: Double = 0.04
    private let previewPokeTheta: Double = 0.35 * Double.pi

    var body: some View {
        GeometryReader { proxy in
            let centre = CGPoint(x: proxy.size.width / 2, y: proxy.size.height / 2)
            Canvas { context, size in
                context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(background))

                if phase == nil, !reduceMotion {
                    sim.ensureCount(modes)
                    sim.step(now: .now, decay: decay)
                }

                let breathe = 1 + 0.03 * sin(2 * .pi * current)
                let canvasCentre = CGPoint(x: size.width / 2, y: size.height / 2)

                var path = Path()
                for step in 0..<sampleCount {
                    let theta = 2 * .pi * Double(step) / Double(sampleCount)
                    let deformation: Double
                    if phase == nil {
                        deformation = sim.deformation(theta: theta) + heldBump(theta: theta, centre: canvasCentre)
                    } else {
                        deformation = previewDeformation(theta: theta, loopT: current)
                    }
                    let scale = max(0.5, min(1.6, 1 + deformation))
                    let r = radius * CGFloat(breathe * scale)
                    let point = CGPoint(x: canvasCentre.x + r * cos(theta), y: canvasCentre.y + r * sin(theta))
                    if step == 0 { path.move(to: point) } else { path.addLine(to: point) }
                }
                path.closeSubpath()

                let shading = GraphicsContext.Shading.radialGradient(
                    Gradient(colors: fill),
                    center: canvasCentre,
                    startRadius: 0,
                    endRadius: radius * 1.1
                )
                context.fill(path, with: shading)
            }
            .contentShape(Rectangle())
            .gesture(dragGesture(centre: centre))
        }
        .onAppear(perform: start)
        .accessibilityHidden(true)
    }

    // MARK: - Live held bump

    /// While a finger is down, the surface pulls toward it directly (not
    /// through the modal sim) so the drag reads as continuous; releasing
    /// hands this bump's angle and reach to the oscillator bank as one kick.
    private func heldBump(theta: Double, centre: CGPoint) -> Double {
        guard let touch = heldTouch else { return 0 }
        let dx = touch.x - centre.x
        let dy = touch.y - centre.y
        guard hypot(dx, dy) > 1 else { return 0 }
        let touchTheta = atan2(dy, dx)
        let diff = wrappedAngleDiff(theta - touchTheta)
        let sigma = 0.55
        let amount = 0.6 * wobble
        return amount * exp(-(diff * diff) / (2 * sigma * sigma))
    }

    // MARK: - Preview scripted poke (mechanism A — closed-form)

    /// The modes are a linear damped system, so a single scripted poke can be
    /// evaluated directly at `phase` with the exact impulse-response formula
    /// instead of integrating — no simulation needed for a deterministic
    /// preview frame.
    private func previewDeformation(theta: Double, loopT: Double) -> Double {
        let dt = (loopT - previewPokeAt) * previewPeriodSeconds
        guard dt > 0 else { return 0 }
        var sum = 0.0
        for index in 0..<max(modes, 0) {
            let n = Double(index + 2)
            let target = wobble / pow(2, Double(index))
            let omega = 2 * .pi * modeFrequency(index)
            let tau = modeTau(index, decay: decay)
            let zeta = min(1 / (tau * omega), 0.95)
            let omegaD = omega * (max(1 - zeta * zeta, 0.0001)).squareRoot()
            let v0 = target * omega
            let envelope = v0 / omegaD * exp(-zeta * omega * dt) * sin(omegaD * dt)
            sum += envelope * cos(n * (theta - previewPokeTheta))
        }
        return sum
    }

    // MARK: - Gesture

    private func dragGesture(centre: CGPoint) -> some Gesture {
        DragGesture(minimumDistance: 0)
            .onChanged { value in
                guard phase == nil, !reduceMotion else { return }
                if touchDownAt == nil {
                    touchDownAt = value.location
                    let dx = value.location.x - centre.x
                    let dy = value.location.y - centre.y
                    let dist = hypot(dx, dy)
                    let theta = atan2(dy, dx)
                    // A poke near the middle does less; the rim gets the full kick.
                    let nearness = max(0.15, min(dist / radius, 1.0))
                    sim.excite(theta: theta, amount: nearness, wobble: wobble)
                }
                heldTouch = value.location
            }
            .onEnded { value in
                defer {
                    heldTouch = nil
                    touchDownAt = nil
                }
                guard phase == nil, !reduceMotion, let start = touchDownAt else { return }
                let dx = value.location.x - centre.x
                let dy = value.location.y - centre.y
                let theta = atan2(dy, dx)
                let dragDistance = hypot(value.location.x - start.x, value.location.y - start.y)
                let amount = min(dragDistance / radius, 1.0)
                guard amount > 0.02 else { return }
                sim.excite(theta: theta, amount: amount, wobble: wobble)
            }
    }

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

private func wrappedAngleDiff(_ angle: Double) -> Double {
    atan2(sin(angle), cos(angle))
}

/// Mode `index` (0-based) is angular order `n = index + 2`. Frequencies climb
/// roughly 0.8Hz per mode and decay times shrink per mode — the higher modes
/// die first, which is what makes this read as jelly rather than rubber.
private func modeFrequency(_ index: Int) -> Double { 2.1 + 0.8 * Double(index) }
private func modeTau(_ index: Int, decay: Double) -> Double { decay * max(0.35, 1 - 0.15 * Double(index)) }

/// A plain, non-observed class holding the modal oscillator bank. Nothing
/// here is `@Observable` — it is mutated directly from inside the `Canvas`
/// closure per the contract's stateful-simulation pattern.
private final class JellySim {
    struct Mode {
        var x: Double = 0
        var vx: Double = 0
        var y: Double = 0
        var vy: Double = 0
    }

    private(set) var modes: [Mode] = []
    private var lastStep: Date?

    func ensureCount(_ count: Int) {
        let clamped = max(count, 0)
        guard modes.count != clamped else { return }
        modes = Array(repeating: Mode(), count: clamped)
    }

    /// Impulse-excite every mode from a touch at `theta`. Each mode carries a
    /// cos-basis and sin-basis oscillator so the kick's direction is captured
    /// without needing to remember which angle excited it — a second kick
    /// from a different angle simply adds into the same two scalars.
    func excite(theta: Double, amount: Double, wobble: Double) {
        for index in modes.indices {
            let n = Double(index + 2)
            let target = wobble / pow(2, Double(index))
            let omega = 2 * .pi * modeFrequency(index)
            let kick = target * amount * omega
            modes[index].vx += kick * cos(n * theta)
            modes[index].vy += kick * sin(n * theta)
        }
    }

    /// Fixed-dt catch-up loop, clamped to a few substeps so a background/
    /// foreground round-trip cannot make the ring-down jump.
    func step(now: Date, decay: Double) {
        let dt = 1.0 / 60
        let elapsed = lastStep.map { now.timeIntervalSince($0) } ?? dt
        lastStep = now
        let steps = max(1, min(Int((elapsed / dt).rounded()), 4))

        for _ in 0..<steps {
            for index in modes.indices {
                let omega = 2 * .pi * modeFrequency(index)
                let zeta = min(1 / (modeTau(index, decay: decay) * omega), 0.95)
                // Copied out and written back so each `advance` holds one
                // exclusive access to `modes`. Passing two fields of the same
                // element as `inout` in one call is an overlapping access and
                // does not compile under optimisation.
                var mode = modes[index]
                advance(&mode.x, &mode.vx, omega: omega, zeta: zeta, dt: dt)
                advance(&mode.y, &mode.vy, omega: omega, zeta: zeta, dt: dt)
                modes[index] = mode
            }
        }
    }

    private func advance(_ x: inout Double, _ v: inout Double, omega: Double, zeta: Double, dt: Double) {
        let acceleration = -omega * omega * x - 2 * zeta * omega * v
        v += acceleration * dt
        x += v * dt
    }

    func deformation(theta: Double) -> Double {
        var sum = 0.0
        for (index, mode) in modes.enumerated() {
            let n = Double(index + 2)
            sum += mode.x * cos(n * theta) + mode.y * sin(n * theta)
        }
        return sum
    }
}

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

SwiftUI note. One filled path of 120 samples per frame — the cheapest, most resolution-independent effect in the set. The 1.0 -> 1.03 breathing never stops, so unlike `pluck-threads` this canvas always redraws; that stays cheap because it's a single path fill, not a full-screen pass. With `phase` set, the wobble is evaluated as a closed-form impulse response at one scripted poke rather than stepped, so the preview needs no simulation at all.

Dependencies

SwiftUI
No external dependencies

Requires iOS 17

Flutter
No external dependencies

Requires Flutter 3.27