What You Will Build

A centered pop-up menu that appears over a dimmed background when triggered by a button tap. The menu lists navigation items, each selectable with a tap. The overlay uses a spring scale transition for a smooth entrance and an outside-tap-to-dismiss behavior. This is useful for action sheets, context menus, or compact navigation in utility apps.

Why This Pattern Matters

Pop-up menus provide quick access to navigation without taking the user to a new screen. Unlike .sheet or .fullScreenCover, a custom overlay gives you full control over sizing, position, animation, and backdrop behavior.

Key SwiftUI Concepts

  • ZStack layering for overlay architecture.
  • .transition(.scale.combined(with: .opacity)) for pop-in entrance.
  • .ultraThickMaterial for a frosted opaque background.
  • Dismiss on outside tap using a transparent background layer.

Step 1: Set Up State

@State private var showPopup = false
@State private var selectedItem: String?

let items = ["Settings", "Profile", "Notifications", "Help", "About"]

Step 2: Build the Trigger Button

Button {
    withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) {
        showPopup.toggle()
    }
} label: {
    Label("Open Menu", systemImage: "line.3.horizontal")
        .fontWeight(.semibold)
        .padding()
        .background(.blue, in: RoundedRectangle(cornerRadius: 12))
        .foregroundStyle(.white)
}

Step 3: Build the Overlay

The overlay consists of two layers: a dimmed backdrop that dismisses on tap, and the actual menu card:

if showPopup {
    // Dimmed backdrop
    Color.black.opacity(0.3)
        .ignoresSafeArea()
        .onTapGesture {
            withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) {
                showPopup = false
            }
        }
        .transition(.opacity)

    // Menu card
    VStack(spacing: 0) {
        ForEach(items, id: \.self) { item in
            Button {
                withAnimation(.spring) {
                    selectedItem = item
                    showPopup = false
                }
            } label: {
                HStack {
                    Text(item)
                        .fontWeight(.medium)
                    Spacer()
                    Image(systemName: "chevron.right")
                        .font(.caption)
                }
                .foregroundStyle(.primary)
                .padding()
            }
            if item != items.last {
                Divider()
            }
        }
    }
    .background(.ultraThickMaterial, in: RoundedRectangle(cornerRadius: 16))
    .padding(.horizontal, 40)
    .transition(.scale(scale: 0.8).combined(with: .opacity))
}

Tips and Pitfalls

  • Wrap everything in a ZStack: The main content and popup overlay must share the same ZStack for proper layering.
  • .transition requires withAnimation: The scale and opacity transitions only animate if the state change is wrapped in withAnimation.
  • Scale starts at 0.8, not 0: Starting from 80% size creates a subtle pop effect. Starting from 0 looks too dramatic.
  • .ultraThickMaterial vs .ultraThinMaterial: Thick material is more opaque and readable for menus. Thin material lets more background through, better for decorative overlays.
  • Accessibility: Add .accessibilityLabel to each menu item and support VoiceOver dismiss gestures.

iOS Version: iOS 15+

Get New Tutorials by Email

No spam. Just clear, practical breakdowns you can apply right away.

Enjoy this tutorial?

Get new practical tech tutorials in your inbox.