
Jelly Blob
From syxUI — written for both platforms, not translated between them.
InteractiveOne 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
- Flutter
- No external dependencies
Requires iOS 17
Requires Flutter 3.27


