How to implement a context menu in SwiftUI
Attach .contextMenu { … } to any SwiftUI view and fill the closure with Button actions — iOS renders a haptic long-press menu automatically. No delegates, no UIKit required.
Text("Hold me")
.contextMenu {
Button("Copy", systemImage: "doc.on.doc") {
UIPasteboard.general.string = "Hello"
}
Button("Share…", systemImage: "square.and.arrow.up") {
// present share sheet
}
Divider()
Button("Delete", systemImage: "trash", role: .destructive) {
// delete action
}
}
Full implementation
The example below builds a note list where each row exposes a context menu with Copy, Pin, and Delete actions. The contextMenu(menuItems:preview:) overload provides a custom card preview while the user holds down, giving the interaction a polished feel that matches system apps like Mail and Notes.
import SwiftUI
struct Note: Identifiable {
let id = UUID()
var title: String
var body: String
var isPinned: Bool = false
}
struct NoteRow: View {
let note: Note
let onPin: () -> Void
let onDelete: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(note.title)
.font(.headline)
Text(note.body)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(2)
}
.padding(.vertical, 4)
// contextMenu with a custom preview (iOS 16+, fully supported on iOS 17+)
.contextMenu {
Button("Copy Title", systemImage: "doc.on.doc") {
UIPasteboard.general.string = note.title
}
Button(
note.isPinned ? "Unpin" : "Pin",
systemImage: note.isPinned ? "pin.slash" : "pin"
) {
onPin()
}
Divider()
Button("Delete Note", systemImage: "trash", role: .destructive) {
onDelete()
}
} preview: {
// Custom preview card shown during long-press
NotePreviewCard(note: note)
.frame(width: 280)
}
// Accessibility: announce what a long press reveals
.accessibilityHint("Long press for options")
}
}
struct NotePreviewCard: View {
let note: Note
var body: some View {
VStack(alignment: .leading, spacing: 8) {
if note.isPinned {
Label("Pinned", systemImage: "pin.fill")
.font(.caption)
.foregroundStyle(.orange)
}
Text(note.title).font(.title3).bold()
Text(note.body).font(.body).foregroundStyle(.secondary)
}
.padding()
.background(.background)
}
}
struct ContentView: View {
@State private var notes = [
Note(title: "Meeting recap", body: "Discussed Q3 goals and sprint plan."),
Note(title: "Book list", body: "Thinking Fast and Slow, Deep Work, SICP."),
Note(title: "Grocery run", body: "Oat milk, sourdough, cherry tomatoes.")
]
var body: some View {
NavigationStack {
List {
ForEach($notes) { $note in
NoteRow(
note: note,
onPin: { note.isPinned.toggle() },
onDelete: {
notes.removeAll { $0.id == note.id }
}
)
}
}
.navigationTitle("Notes")
}
}
}
#Preview {
ContentView()
}
How it works
- .contextMenu { … } — The modifier intercepts a long-press gesture on any view. SwiftUI blurs the background, lifts the view, and presents the menu items you return from the closure. No gesture recognisers to manage.
-
Button with
systemImage:— Passing a system image label places an SF Symbol beside the button title automatically. Therole: .destructiveparameter colours the Delete button red and places it at the bottom of the menu, matching HIG guidance. -
preview:closure — ThecontextMenu(menuItems:preview:)overload lets you supply any SwiftUI view as the floating preview card. HereNotePreviewCardshows pinned state and full body text while the menu is open. -
Divider() — Placing a
Dividerinside the menu closure inserts a visual separator, grouping safe actions above and destructive ones below — exactly what system apps do. -
.accessibilityHint — Because VoiceOver users cannot long-press in the usual way, the hint tells them how to reveal extra options. Consider also exposing the same actions via
.accessibilityActionfor a fully accessible experience.
Variants
Context menu on a List row (swipe actions + menu together)
List(notes) { note in
Text(note.title)
.swipeActions(edge: .trailing) {
Button("Delete", role: .destructive) { /* … */ }
}
.contextMenu {
Button("Copy", systemImage: "doc.on.doc") { /* … */ }
Button("Delete", systemImage: "trash", role: .destructive) { /* … */ }
}
}
// Both interactions coexist independently —
// swipe for quick delete, long-press for the full menu.
Conditional menu items
Because the closure is a @ViewBuilder, you can use if / if let to show items conditionally — for example hiding "Unpin" when the note is not pinned, or showing an "Open in Safari" button only when a URL is present. SwiftUI rebuilds the menu each time it is presented, so @State changes are reflected immediately.
Common pitfalls
-
iOS version: The
preview:closure overload was introduced in iOS 16, but on iOS 17 it gained support insideListrows. If you target iOS 15, fall back to the no-preview form or guard withif #available(iOS 16, *). -
Menu not appearing on Button: If you attach
.contextMenuto aButton, the tap gesture can intercept the long-press on some devices. Wrap the button's label in a plainLabelview and use.contextMenuon a parent container instead. -
Accessibility: VoiceOver activates context menus via the "Actions" rotor, but only if you also add
.accessibilityAction(named:)entries. The.contextMenuclosure is not automatically surfaced to assistive technology — always pair it with explicit accessibility actions for destructive operations.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement a context menu in SwiftUI for iOS 17+. Use .contextMenu with a preview: closure. Include Copy, Pin/Unpin, and Delete (role: .destructive) actions. Make it accessible (VoiceOver labels and .accessibilityAction). Add a #Preview with realistic sample data.
In Soarias's Build phase, drop this prompt into the active screen file — Claude Code will wire the menu directly into your existing list row component and update the data model in one pass.
Related
FAQ
Does this work on iOS 16?
Yes — the basic .contextMenu { … } modifier works back to iOS 15. The preview: closure was added in iOS 16. Full List row preview support and primaryAction: landed in iOS 16 as well, so for iOS 15 targets omit the preview parameter.
Can I trigger a context menu programmatically (without a long press)?
Not directly via SwiftUI's public API as of iOS 17. The system exclusively triggers .contextMenu on long-press or secondary click (iPad / Mac Catalyst). If you need a programmatically shown menu, use Menu { … } label: { … } instead — it renders a tappable button that reveals the same menu immediately on tap.
What is the UIKit equivalent?
In UIKit you'd implement UIContextMenuConfiguration via tableView(_:contextMenuConfigurationForRowAt:point:) (or the equivalent collection view delegate) and return a UIMenu populated with UIAction items. SwiftUI's .contextMenu wraps all of that automatically — far less boilerplate.
Last reviewed: 2026-05-11 by the Soarias team.