Morphing Submit Button
A submit button that morphs into a spinner while it works and then into a checkmark.

- Buttons
- iOS 18.0+
- intermediate
- Origin
- Original work
- Found by
- submit · loading · morph · spinner · success · async button · progress · enviar · cargando · transformación · indicador de carga · éxito · botón asíncrono
Description
Section titled “Description”A submit button that collapses into a spinner while its async action runs, then turns into a checkmark before returning to normal.
With Reduce Motion on, the button keeps its shape and only its content crossfades.
struct MorphingSubmitButtonDemo: View { var body: some View { VStack(spacing: 32) { VStack(spacing: 10) { Image(systemName: "paperplane.fill") .font(.largeTitle) .foregroundStyle(.blue) .accessibilityHidden(true) Text("Join the waitlist") .font(.largeTitle.bold()) Text("We will write when your spot opens.") .font(.body) .foregroundStyle(.secondary) }
MorphingSubmitButton(title: "Submit") { try? await Task.sleep(for: .seconds(1.4)) } .frame(height: 80) } .multilineTextAlignment(.center) .padding(24) .frame(maxWidth: .infinity, maxHeight: .infinity) }}API reference
Section titled “API reference”| Parameter | Type | Default |
|---|---|---|
title | LocalizedStringResource | Required |
tint | Color | .blue |
successColor | Color | .green |
successDuration | Double | 1.2 |
action | @MainActor () async -> Void | Required |
Copy this code into your project, or download the file.
// SPDX-License-Identifier: MIT// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
private enum MorphingSubmitButtonPhase { case idle, loading, success}
/// A submit button that collapses into a spinner while its async action runs,/// then turns into a checkmark before returning to normal.////// With Reduce Motion on, the button keeps its shape and only its content crossfades.struct MorphingSubmitButton: View { let title: LocalizedStringResource var tint: Color = .blue var successColor: Color = .green var successDuration: Double = 1.2 let action: @MainActor () async -> Void
@State private var phase = MorphingSubmitButtonPhase.idle @ScaledMetric(relativeTo: .headline) private var height = 64.0 @Environment(\.accessibilityReduceMotion) private var reduceMotion
private var morph: Animation { reduceMotion ? .easeInOut(duration: 0.2) : .spring(duration: 0.5, bounce: 0.3) }
private var isCollapsed: Bool { phase != .idle && !reduceMotion }
private var accessibilityTitle: LocalizedStringResource { switch phase { case .idle: title case .loading: "Submitting" case .success: "Done" } }
var body: some View { Button { submit() } label: { ZStack { Text(title) .font(.title3.bold()) .fixedSize() .opacity(phase == .idle ? 1 : 0)
if phase == .loading { ProgressView() .tint(MorphingSubmitButtonContrastingForeground(background: phase == .success ? successColor : tint)) .transition(.opacity) }
if phase == .success { Image(systemName: "checkmark") .font(.title2.bold()) .transition(reduceMotion ? .opacity : .scale.combined(with: .opacity)) } } .foregroundStyle(MorphingSubmitButtonContrastingForeground(background: phase == .success ? successColor : tint)) .padding(.horizontal, isCollapsed ? 0 : 52) .frame(width: isCollapsed ? height : nil, height: height) .background( phase == .success ? successColor : tint, in: RoundedRectangle(cornerRadius: isCollapsed ? height / 2 : 18, style: .continuous) ) .contentShape(.rect) } .buttonStyle(.plain) .sensoryFeedback(.success, trigger: phase) { _, newPhase in newPhase == .success } .accessibilityLabel(accessibilityTitle) }
private func submit() { guard phase == .idle else { return } withAnimation(morph) { phase = .loading }
Task { await action() withAnimation(morph) { phase = .success } try? await Task.sleep(for: .seconds(successDuration)) withAnimation(morph) { phase = .idle } } }}
/// White or black, whichever reads better on `background`. Black only on a/// light color, so the system's own colors keep white.nonisolated struct MorphingSubmitButtonContrastingForeground: ShapeStyle { let background: Color
func resolve(in environment: EnvironmentValues) -> Color { let color = background.resolve(in: environment) let luminance = 0.2126 * color.linearRed + 0.7152 * color.linearGreen + 0.0722 * color.linearBlue return luminance > 0.5 ? .black : .white }}
// MARK: - Demo
struct MorphingSubmitButtonDemo: View { var body: some View { VStack(spacing: 32) { VStack(spacing: 10) { Image(systemName: "paperplane.fill") .font(.largeTitle) .foregroundStyle(.blue) .accessibilityHidden(true) Text("Join the waitlist") .font(.largeTitle.bold()) Text("We will write when your spot opens.") .font(.body) .foregroundStyle(.secondary) }
MorphingSubmitButton(title: "Submit") { try? await Task.sleep(for: .seconds(1.4)) } .frame(height: 80) } .multilineTextAlignment(.center) .padding(24) .frame(maxWidth: .infinity, maxHeight: .infinity) }}
#Preview { MorphingSubmitButtonDemo()}