Skip to content
Morph Island preview
An animated render of the SwiftUI source on this page.

Morph Island

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

Interactive

A compact pill that swells into a panel, width leading and height arriving late.

Feedback · SwiftUI · Flutter · iOS 17 · Flutter 3.27

  • island
  • morph
  • pill
  • panel
  • expand
  • spring
  • animated

The actual source

MorphIsland.swift
// Morph Island · syxUI · https://syxui.dev/components/morph-island
// Free in any project. Keep this line and credit syxUI where a person can read it.

import SwiftUI

/// A compact pill that swells into a panel.
///
/// Width and height deliberately do not share a spring. Width leads and lands
/// inside the first 80% of the morph; height sets off `heightLag` later and
/// arrives with a small overshoot, after the width has already stopped. That
/// mismatch is the whole effect — it reads as surface tension rather than a box
/// being resized.
///
/// The corner radius is always `min(w, h) / 2`, so the shape never passes
/// through a "rounded rectangle" moment on its way out of the capsule. And
/// because every dimension is derived from one drive, the same code renders a
/// deterministic frame from `progress` and a live spring from a tap.
struct MorphIsland: View {
    var pill: Color = Color(white: 0.0)
    var accent: Color = Color(red: 0.204, green: 0.827, blue: 0.6)
    var textColor: Color = Color(white: 1.0)
    var openWidth: CGFloat = 360
    var openHeight: CGFloat = 84
    /// How far behind the width the height sets off, as a fraction of the
    /// morph rather than a number of seconds — so the lag stays right when the
    /// spring response changes.
    var heightLag: Double = 0.13
    /// Preview override. 0 is the closed pill, 1 the open panel.
    var progress: Double? = nil

    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @State private var widthDrive: Double = 0
    @State private var heightDrive: Double = 0
    @State private var isOpen = false

    private let closedWidth: CGFloat = 122
    private let closedHeight: CGFloat = 36

    /// Width travel. In controlled mode the two springs are replayed as eases
    /// with the same shape, so a sampled frame matches what a tap produces.
    private var wp: Double {
        guard let progress else { return widthDrive }
        return backOut(ramp(progress, from: 0, to: 0.80), tension: 0.7)
    }

    /// Height travel: starts late, then overshoots by roughly 6%.
    private var hp: Double {
        guard let progress else { return heightDrive }
        return springOut(ramp(progress, from: heightLag, to: 1.0))
    }

    private var width: CGFloat { closedWidth + (openWidth - closedWidth) * wp }
    private var height: CGFloat { closedHeight + (openHeight - closedHeight) * hp }

    /// Compact contents clear out early, over the first third of the width
    /// travel — long before the panel is wide enough to hold anything else.
    private var compactOut: Double { 1 - clamp(wp / 0.35) }

    /// Expanded contents ride the *height*, so they arrive with the overshoot
    /// instead of ahead of it.
    private var expandedIn: Double { clamp((hp - 0.55) / 0.45) }

    var body: some View {
        let shape = RoundedRectangle(cornerRadius: min(width, height) / 2, style: .continuous)

        return ZStack {
            compact
                .opacity(compactOut)
                .scaleEffect(0.5 + 0.5 * compactOut)

            expanded
                .opacity(expandedIn)
                .scaleEffect(0.94 + 0.06 * expandedIn)
        }
        .frame(width: width, height: height)
        .background(pill, in: shape)
        .clipShape(shape)
        .contentShape(shape)
        .onTapGesture(perform: toggle)
        .sensoryFeedback(.impact(flexibility: .soft), trigger: isOpen)
        .accessibilityElement()
        .accessibilityAddTraits(.isButton)
        .accessibilityLabel("Now playing, Midnight City by M83")
        .accessibilityValue(isOpen ? "Expanded" : "Collapsed")
    }

    /// The live-activity state: one status dot and a four-bar meter.
    private var compact: some View {
        HStack(spacing: 0) {
            Circle()
                .fill(accent)
                .frame(width: 11, height: 11)

            Spacer(minLength: 8)

            HStack(alignment: .center, spacing: 2.5) {
                ForEach([0.42, 0.86, 0.58, 1.0], id: \.self) { unit in
                    Capsule(style: .continuous)
                        .fill(accent)
                        .frame(width: 2.5, height: 14 * unit)
                }
            }
        }
        .padding(.horizontal, 13)
        .frame(width: closedWidth, height: closedHeight)
    }

    /// The panel state. Laid out at its full size at all times and clipped by
    /// the pill, so text never reflows mid-morph.
    private var expanded: some View {
        VStack(alignment: .leading, spacing: 11) {
            HStack(spacing: 12) {
                RoundedRectangle(cornerRadius: 11, style: .continuous)
                    .fill(
                        LinearGradient(
                            colors: [accent, accent.opacity(0.55)],
                            startPoint: .topLeading,
                            endPoint: .bottomTrailing
                        )
                    )
                    .frame(width: 40, height: 40)
                    .overlay {
                        Image(systemName: "waveform")
                            .font(.system(size: 15, weight: .semibold))
                            .foregroundStyle(pill)
                    }

                VStack(alignment: .leading, spacing: 2) {
                    Text("Midnight City")
                        .font(.system(size: 14, weight: .semibold))
                        .foregroundStyle(textColor)
                    Text("M83")
                        .font(.system(size: 11, weight: .medium))
                        .foregroundStyle(textColor.opacity(0.55))
                }
                .lineLimit(1)

                Spacer(minLength: 0)

                Image(systemName: "pause.fill")
                    .font(.system(size: 15, weight: .semibold))
                    .foregroundStyle(textColor.opacity(0.85))
            }

            // Grows from the middle outward, so the bar reads as part of the
            // surface stretching rather than a separate progress reveal.
            GeometryReader { geometry in
                ZStack {
                    Capsule(style: .continuous)
                        .fill(textColor.opacity(0.16))
                    Capsule(style: .continuous)
                        .fill(accent)
                        .frame(width: geometry.size.width * 0.46 * expandedIn)
                }
            }
            .frame(height: 2.5)
        }
        .padding(.horizontal, 16)
        .frame(width: openWidth, height: openHeight)
    }

    private func toggle() {
        guard progress == nil else { return }

        let next: Double = isOpen ? 0 : 1
        isOpen.toggle()

        guard !reduceMotion else {
            widthDrive = next
            heightDrive = next
            return
        }

        // Two springs, mismatched on purpose. Both retarget from wherever they
        // are, so a second tap reverses the morph instead of queueing behind
        // it — and the velocity already in the system carries over.
        withAnimation(.spring(response: 0.40, dampingFraction: 0.85)) {
            widthDrive = next
        }
        withAnimation(.spring(response: 0.34, dampingFraction: 0.68).delay(heightLag * 0.46)) {
            heightDrive = next
        }
    }

    private func clamp(_ value: Double) -> Double { min(max(value, 0), 1) }

    private func ramp(_ value: Double, from: Double, to: Double) -> Double {
        clamp((value - from) / (to - from))
    }

    /// Ease-out with a small overshoot. Tension 0.7 lands about 4% past the
    /// target, which is what a 0.85-damped spring looks like.
    private func backOut(_ x: Double, tension: Double) -> Double {
        let c = clamp(x)
        return 1 + (tension + 1) * pow(c - 1, 3) + tension * pow(c - 1, 2)
    }

    /// One damped bounce: rises fast, overshoots ~6%, settled by 1.
    private func springOut(_ x: Double) -> Double {
        let c = clamp(x)
        return 1 - pow(2, -10 * c) * cos(c * 7.6)
    }
}

#Preview {
    VStack(spacing: 40) {
        MorphIsland()
        MorphIsland(progress: 0.66)
        MorphIsland(progress: 1)
    }
    .padding(40)
}
iOS 17 · No dependencies

SwiftUI note. Width and height run on separate springs; `heightLag` is what makes it read as surface tension rather than a resize. Tapping drives it live, and `progress` renders any frame of the same morph deterministically.

Dependencies

SwiftUI
No external dependencies

Requires iOS 17

Flutter
No external dependencies

Requires Flutter 3.27