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


