swiftui · tutorial · animation · components

Building an expandable add button in SwiftUI

Published January 30, 2026 · Reviewed February 18, 2026 · 3 min read · beginner

What we’re building

A simple expandable action button in the top-right corner:

  • A plus icon
  • Tapping it rotates the plus into an ×
  • Two secondary actions appear:
    • From Photo Library
    • From Camera
  • Tapping again closes the menu

No UIKit. No custom shapes. Pure SwiftUI.


The mental model

There is only one piece of state:

isOpen = false
isOpen = true

Everything else derives from that single boolean:

  • rotation
  • visibility
  • animation
  • hit testing

SwiftUI recomputes the view based on state instead of you manually opening or closing anything.


Step 1: State and container

struct AddMenuView: View {
    @State private var isOpen = false

    var body: some View {
        ZStack(alignment: .topTrailing) {
            Color.clear
            menu
        }
    }
}
  • @State belongs to the owner of the interaction
  • ZStack lets us layer content
  • .topTrailing pins the menu to the corner

Step 2: The main add button

private var menu: some View {
    VStack(alignment: .trailing, spacing: 12) {

        if isOpen {
            subButtons
        }

        Button {
            withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
                isOpen.toggle()
            }
        } label: {
            Image(systemName: "plus")
                .rotationEffect(.degrees(isOpen ? 45 : 0))
                .foregroundStyle(.white)
                .frame(width: 44, height: 44)
                .background(Circle().fill(Color.accentColor))
                .shadow(radius: 4)
        }
    }
    .padding()
}
  • Rotating the plus icon by 45° visually reads as a close symbol
  • The same button opens and closes the menu
  • Animation is fully driven by state changes

Step 3: The secondary actions

private var subButtons: some View {
    VStack(alignment: .trailing, spacing: 8) {

        actionButton(
            title: "From Photo Library",
            systemImage: "photo.on.rectangle"
        ) {
            print("Photo library tapped")
        }

        actionButton(
            title: "From Camera",
            systemImage: "camera"
        ) {
            print("Camera tapped")
        }
    }
    .transition(
        .opacity.combined(with: .move(edge: .top))
    )
}

The transition describes how the buttons enter and leave the view hierarchy:

  • fade in and slide down when appearing
  • reverse when disappearing

Step 4: A reusable action button

private func actionButton(
    title: String,
    systemImage: String,
    action: @escaping () -> Void
) -> some View {
    Button(action: action) {
        Label(title, systemImage: systemImage)
            .font(.subheadline)
            .padding(.horizontal, 12)
            .padding(.vertical, 8)
            .background(
                Capsule().fill(Color(.systemGray6))
            )
    }
}

Extracting this keeps the main view readable and makes it easy to add more actions later.


Full working example

struct AddMenuView: View {
    @State private var isOpen = false

    var body: some View {
        ZStack(alignment: .topTrailing) {
            Color.clear

            VStack(alignment: .trailing, spacing: 12) {

                if isOpen {
                    VStack(alignment: .trailing, spacing: 8) {
                        actionButton(
                            title: "From Photo Library",
                            systemImage: "photo.on.rectangle"
                        ) {}

                        actionButton(
                            title: "From Camera",
                            systemImage: "camera"
                        ) {}
                    }
                    .transition(
                        .opacity.combined(with: .move(edge: .top))
                    )
                }

                Button {
                    withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
                        isOpen.toggle()
                    }
                } label: {
                    Image(systemName: "plus")
                        .rotationEffect(.degrees(isOpen ? 45 : 0))
                        .foregroundStyle(.white)
                        .frame(width: 44, height: 44)
                        .background(Circle().fill(Color.accentColor))
                        .shadow(radius: 4)
                }
            }
            .padding()
        }
    }

    private func actionButton(
        title: String,
        systemImage: String,
        action: @escaping () -> Void
    ) -> some View {
        Button(action: action) {
            Label(title, systemImage: systemImage)
                .font(.subheadline)
                .padding(.horizontal, 12)
                .padding(.vertical, 8)
                .background(
                    Capsule().fill(Color(.systemGray6))
                )
        }
    }
}

Key takeaways

  • SwiftUI menus are just state-driven views
  • Rotation, transitions, and visibility come from a single source of truth
  • Avoid imperative show/hide logic
  • Describe how the UI looks for each state

This pattern scales well from simple menus to complex, reusable components.


Mental model

This tutorial builds on the idea that views are lightweight descriptions:

Related