```html SwiftUI: How to Context Menu (iOS 17+, 2026)

How to implement a context menu in SwiftUI

iOS 17+ Xcode 16+ Beginner APIs: contextMenu Updated: May 11, 2026
TL;DR

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

  1. .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.
  2. Button with systemImage: — Passing a system image label places an SF Symbol beside the button title automatically. The role: .destructive parameter colours the Delete button red and places it at the bottom of the menu, matching HIG guidance.
  3. preview: closure — The contextMenu(menuItems:preview:) overload lets you supply any SwiftUI view as the floating preview card. Here NotePreviewCard shows pinned state and full body text while the menu is open.
  4. Divider() — Placing a Divider inside the menu closure inserts a visual separator, grouping safe actions above and destructive ones below — exactly what system apps do.
  5. .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 .accessibilityAction for 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

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.

```