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
.accessibilityLabelto each menu item and support VoiceOver dismiss gestures.
iOS Version: iOS 15+