Skip to content
Form Expand preview
An animated render of the SwiftUI source on this page.

Form Expand

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

Interactive

A pill button that grows into a form, then collapses to a success pill with a drawn tick.

Cards · SwiftUI · Flutter · iOS 17 · Flutter 3.27

  • card
  • form
  • expand
  • morph
  • inline
  • button
  • success
  • three-state

The actual source

MorphFormExpand.swift
// Form Expand · syxUI · https://syxui.dev/components/morph-form-expand
// Free in any project. Keep this line and credit syxUI where a person can read it.

import SwiftUI

/// A pill button that grows into a form, then collapses to a success pill
/// with a drawn checkmark.
///
/// Everything rides one `p` in 0...2: 0...1 opens the panel, 1...2 submits
/// it. Splitting the range this way means "submit" is not a second
/// transition wired to a boolean — it's the same progress axis continuing
/// past 1, so a scrubbed preview can stop anywhere in either half and still
/// read correctly. The label never fades out and back in; it is one `Text`
/// that slides and grows into the header, which is what sells the morph.
struct MorphFormExpand: View {
    var accent: Color = Color(red: 0.184, green: 0.435, blue: 0.929)
    var surface: Color = Color(white: 1.0)
    var fieldColor: Color = Color(red: 0.949, green: 0.953, blue: 0.969)
    var successColor: Color = Color(red: 0.086, green: 0.639, blue: 0.290)
    var openHeight: CGFloat = 300
    /// Per-field reveal delay, as a fraction of the opening half of `p` —
    /// not seconds, so it stays right whether `p` is spring-driven or
    /// scrubbed by the preview.
    var stagger: Double = 0.05
    /// Preview override. 0 is the closed button, 1 the open form, 2 the
    /// success pill.
    var progress: Double? = nil

    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @State private var interactive: Double = 0
    @State private var stage: Stage = .closed
    @State private var cardholderName = ""
    @State private var cardNumber = ""

    private enum Stage { case closed, open, success }

    private var p: Double { progress ?? interactive }

    private let width: CGFloat = 280
    private let pillHeight: CGFloat = 56
    private let closedRadius: CGFloat = 28
    private let openRadius: CGFloat = 20

    /// 0 before the panel starts opening, 1 once it is fully open.
    private var openAmount: Double { ramp(p, 0, 1) }
    /// 0 until submit begins, 1 once the panel has re-collapsed to a pill.
    private var collapseAmount: Double { ramp(p, 1, 1.5) }
    /// 0 until the pill has settled, 1 once the checkmark is fully drawn.
    private var tickAmount: Double { ramp(p, 1.5, 2) }

    private var height: CGFloat {
        lerp(lerp(pillHeight, openHeight, openAmount), pillHeight, collapseAmount)
    }
    private var radius: CGFloat {
        lerp(lerp(closedRadius, openRadius, openAmount), closedRadius, collapseAmount)
    }

    private var labelScale: Double { lerp(13, 17, openAmount) / 17 }
    private var labelRise: CGFloat { lerp(0, -(openHeight / 2 - 40), openAmount) }
    private var labelOpacity: Double { 1 - collapseAmount }
    private var formOpacity: Double { openAmount * (1 - collapseAmount) }
    private var successOpacity: Double { collapseAmount }

    var body: some View {
        let shape = RoundedRectangle(cornerRadius: radius, style: .continuous)

        ZStack {
            // The base fill starts as the button colour; surface and the
            // success tint cross-fade on top of it rather than the colour
            // itself being interpolated, so every intermediate frame is a
            // real flat colour instead of a muddy RGB blend.
            shape.fill(accent)
            shape.fill(surface).opacity(openAmount)
            shape.fill(successColor).opacity(successOpacity)

            label
            form.opacity(formOpacity)
            success.opacity(successOpacity)
        }
        .frame(width: width, height: height)
        .clipShape(shape)
        .clipped()
        .contentShape(shape)
        .onTapGesture(perform: open)
        .sensoryFeedback(.impact(flexibility: .soft), trigger: stage)
        .accessibilityElement()
        .accessibilityAddTraits(.isButton)
        .accessibilityLabel(accessibilityText)
    }

    private var accessibilityText: String {
        switch stage {
        case .closed: return "Add payment method"
        case .open: return "Add payment method form, save card to submit"
        case .success: return "Payment method added"
        }
    }

    private var label: some View {
        let text = Text("Add payment method")
            .font(.system(size: 17, weight: .semibold))
            .lineLimit(1)
        return ZStack {
            text.foregroundStyle(.white).opacity(1 - openAmount)
            text.foregroundStyle(Color(white: 0.1)).opacity(openAmount)
        }
        .scaleEffect(labelScale)
        .offset(y: labelRise)
        .opacity(labelOpacity)
        .accessibilityHidden(true)
    }

    private var form: some View {
        VStack(spacing: 14) {
            Spacer().frame(height: 34)
            field("Cardholder name", text: $cardholderName, index: 0)
            field("Card number", text: $cardNumber, index: 1)
            submitButton
        }
        .padding(.horizontal, 22)
        .padding(.bottom, 20)
        .frame(width: width, height: openHeight, alignment: .top)
        .accessibilityHidden(stage != .open)
    }

    private func field(_ placeholder: String, text: Binding<String>, index: Int) -> some View {
        TextField(placeholder, text: text)
            .textFieldStyle(.plain)
            .font(.system(size: 14))
            .padding(.horizontal, 14)
            .frame(height: 46)
            .background(fieldColor, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
            .opacity(fieldOpacity(index))
            .offset(y: fieldOffset(index))
    }

    private var submitButton: some View {
        Text("Save card")
            .font(.system(size: 15, weight: .semibold))
            .foregroundStyle(.white)
            .frame(maxWidth: .infinity)
            .frame(height: 46)
            .background(accent, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
            .opacity(fieldOpacity(2))
            .offset(y: fieldOffset(2))
            .onTapGesture(perform: submit)
    }

    /// The three staggered items: field one, field two, the submit button.
    private func fieldOpacity(_ index: Int) -> Double {
        let start = 0.12 + stagger * Double(index)
        return ramp(openAmount, start, start + 0.22)
    }
    private func fieldOffset(_ index: Int) -> CGFloat {
        12 * (1 - fieldOpacity(index))
    }

    private var checkmark: Path {
        Path { path in
            path.move(to: CGPoint(x: 0, y: 6.5))
            path.addLine(to: CGPoint(x: 6, y: 12))
            path.addLine(to: CGPoint(x: 16, y: 0))
        }
    }

    private var success: some View {
        HStack(spacing: 10) {
            checkmark
                .trim(from: 0, to: tickAmount)
                .stroke(Color.white, style: StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round))
                .frame(width: 16, height: 12)
            Text("Added")
                .font(.system(size: 15, weight: .semibold))
                .foregroundStyle(.white)
                .opacity(tickAmount)
        }
        .accessibilityHidden(true)
    }

    private func open() {
        guard progress == nil, stage == .closed else { return }
        stage = .open
        withAnimation(reduceMotion ? .linear(duration: 0) : .spring(response: 0.40, dampingFraction: 0.90)) {
            interactive = 1
        }
    }

    /// Submit runs as two chained segments on the same value — the panel
    /// re-collapses first, and only once it has settled does the checkmark
    /// begin drawing, matching "starting once the height has settled".
    private func submit() {
        guard progress == nil, stage == .open else { return }
        stage = .success

        let collapseCurve: Animation = reduceMotion ? .linear(duration: 0) : .easeOut(duration: 0.30)
        let tickCurve: Animation = reduceMotion ? .linear(duration: 0) : .easeOut(duration: 0.35)

        withAnimation(collapseCurve) { interactive = 1.5 }
        withAnimation(tickCurve.delay(reduceMotion ? 0 : 0.30)) { interactive = 2 }

        let resetDelay = reduceMotion ? 1.2 : 2.2
        DispatchQueue.main.asyncAfter(deadline: .now() + resetDelay) {
            guard stage == .success else { return }
            stage = .closed
            cardholderName = ""
            cardNumber = ""
            withAnimation(reduceMotion ? .linear(duration: 0) : .spring(response: 0.42, dampingFraction: 0.92)) {
                interactive = 0
            }
        }
    }
}

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))
}

private func lerp(_ a: CGFloat, _ b: CGFloat, _ t: Double) -> CGFloat {
    a + (b - a) * CGFloat(t)
}

private func lerp(_ a: Double, _ b: Double, _ t: Double) -> Double {
    a + (b - a) * t
}

#Preview {
    VStack(spacing: 40) {
        MorphFormExpand()
        MorphFormExpand(progress: 1.0)
        MorphFormExpand(progress: 2.0)
    }
    .padding(40)
}
iOS 17 · No dependencies

SwiftUI note. progress runs 0…1 to open and 1…2 to submit, so a scrubbed preview can stop anywhere in either half and still read correctly. The background never interpolates colour directly — surface and successColor are separate fills cross-fading on top of accent — which keeps every frame a flat, real colour.

Dependencies

SwiftUI
No external dependencies

Requires iOS 17

Flutter
No external dependencies

Requires Flutter 3.27