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

Liquid Blob

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

Interactive

A bezier blob that breathes and morphs between silhouettes without ever creasing.

Backgrounds · SwiftUI · Flutter · iOS 17 · Flutter 3.27

  • blob
  • morph
  • bezier
  • liquid
  • organic
  • gradient
  • loop
  • animated

The actual source

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

import SwiftUI

/// A closed bezier blob whose outline is driven by two continuous knobs:
/// a wobble `phase` that never stops, and a `morph` amount that blends
/// between three named silhouettes.
///
/// Both live in one `animatableData` pair on the underlying `Shape`, so a tap
/// mid-wobble retargets the silhouette without ever swapping views — the
/// wobble keeps running through the transition instead of freezing for it.
struct MorphBlob: View {
    var fills: [Color] = [
        Color(red: 0.486, green: 0.361, blue: 1.0),
        Color(red: 0.925, green: 0.282, blue: 0.600),
    ]
    var background: Color = Color(red: 0.043, green: 0.051, blue: 0.078)

    /// Vertices on the modulated circle. 8 is the sweet spot; below 6 the
    /// four-lobed silhouette loses a lobe, above 14 the wobble reads as noise.
    var points: Int = 8

    /// Radius modulation depth. 0 is a dead, static outline.
    var wobble: Double = 0.12
    var period: Double = 6
    var blur: Double = 0

    /// Preview override. When set, this samples one wobble cycle instead of
    /// driving its own clock.
    var phase: Double? = nil

    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @State private var animatedPhase: Double = 0
    @State private var silhouette = 0
    @State private var animatedMorph: Double = 0

    private var currentPhase: Double { phase ?? animatedPhase }

    var body: some View {
        ZStack {
            background
            MorphBlobOutline(
                phase: currentPhase,
                morph: animatedMorph,
                wobble: wobble,
                points: max(points, 3)
            )
            .fill(
                LinearGradient(
                    colors: fills,
                    startPoint: .topLeading,
                    endPoint: .bottomTrailing
                )
            )
            .blur(radius: blur)
        }
        .clipped()
        .onAppear(perform: startWobble)
        .onTapGesture(perform: advanceSilhouette)
        .accessibilityHidden(true)
    }

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

    /// Cycles round blob → rounded square → four-lobed → round blob. The
    /// wobble animation above is untouched by this, so the two motions
    /// compose rather than fight each other.
    private func advanceSilhouette() {
        guard phase == nil, !reduceMotion else { return }
        silhouette = (silhouette + 1) % 3
        withAnimation(.easeInOut(duration: 0.9)) {
            animatedMorph = Double(silhouette)
        }
    }
}

/// The blob outline: `points` vertices on a circle, radius-modulated per
/// vertex, joined by cubic beziers whose handles are tangent to the circle.
///
/// Handle length is the standard `4/3 · tan(π/2n)` fraction of the local
/// radius that makes an n-gon of bezier arcs approximate a circle without any
/// crease — get that constant wrong and every intermediate frame of the morph
/// kinks at the vertices instead of flowing through them.
private struct MorphBlobOutline: Shape {
    var phase: Double
    var morph: Double
    var wobble: Double
    var points: Int

    var animatableData: AnimatablePair<Double, Double> {
        get { AnimatablePair(phase, morph) }
        set {
            phase = newValue.first
            morph = newValue.second
        }
    }

    func path(in rect: CGRect) -> Path {
        let n = max(points, 3)
        let centre = CGPoint(x: rect.midX, y: rect.midY)
        // Inset from the frame so the wobble has headroom to swell outward
        // without clipping against the bounds.
        let baseRadius = min(rect.width, rect.height) / 2 * 0.76
        let kappa = 4.0 / 3.0 * tan(.pi / (2 * Double(n)))

        var vertices: [CGPoint] = []
        var tangents: [(dx: Double, dy: Double)] = []
        var radii: [Double] = []
        vertices.reserveCapacity(n)
        tangents.reserveCapacity(n)
        radii.reserveCapacity(n)

        for i in 0..<n {
            let theta = 2 * Double.pi * Double(i) / Double(n)
            let silhouette = Self.silhouetteRadius(theta: theta, morph: morph)
            // Two out-of-phase sines per vertex, the second at a different
            // rate and offset — the thing that keeps a wobble loop from
            // reading as a single pulsing ring.
            let wobbleFactor = 1 + wobble * (
                sin(2 * Double.pi * (phase + Double(i) / Double(n))) * 0.6
                + sin(2 * Double.pi * (1.7 * phase + Double(i) / 5)) * 0.4
            )
            let radius = Double(baseRadius) * silhouette * wobbleFactor
            radii.append(radius)
            vertices.append(CGPoint(
                x: centre.x + CGFloat(radius * cos(theta)),
                y: centre.y + CGFloat(radius * sin(theta))
            ))
            tangents.append((dx: -sin(theta), dy: cos(theta)))
        }

        var path = Path()
        path.move(to: vertices[0])
        for i in 0..<n {
            let j = (i + 1) % n
            let h1 = CGFloat(kappa * radii[i])
            let h2 = CGFloat(kappa * radii[j])
            let control1 = CGPoint(
                x: vertices[i].x + CGFloat(tangents[i].dx) * h1,
                y: vertices[i].y + CGFloat(tangents[i].dy) * h1
            )
            let control2 = CGPoint(
                x: vertices[j].x - CGFloat(tangents[j].dx) * h2,
                y: vertices[j].y - CGFloat(tangents[j].dy) * h2
            )
            path.addCurve(to: vertices[j], control1: control1, control2: control2)
        }
        path.closeSubpath()
        return path
    }

    /// Blends round → rounded-square → four-lobed as `morph` runs 0...2.
    /// All three are closed-form radius functions of angle, so they work for
    /// any vertex count instead of needing a hand-authored point set per
    /// silhouette.
    private static func silhouetteRadius(theta: Double, morph: Double) -> Double {
        let round = 1.0
        let c = abs(cos(theta))
        let s = abs(sin(theta))
        let squareExponent = 4.0
        // Polar form of a superellipse: a closed-form "rounded square" whose
        // corners bulge to `sqrt(2)` and whose edge midpoints pull in to 1.
        let square = 1.0 / pow(pow(c, squareExponent) + pow(s, squareExponent), 1.0 / squareExponent)
        let lobed = 1.0 + 0.35 * cos(4 * theta)

        let clamped = min(max(morph, 0), 2)
        if clamped <= 1 {
            return round + (square - round) * clamped
        }
        return square + (lobed - square) * (clamped - 1)
    }
}

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

SwiftUI note. The Shape's animatableData carries phase and morph together, so a tap mid-wobble retargets the silhouette instead of restarting the loop. Raise points past 14 only if you also raise wobble — a dense, still lattice reads flat.

Dependencies

SwiftUI
No external dependencies

Requires iOS 17

Flutter
No external dependencies

Requires Flutter 3.27