Context Menu Preview Card
A card that lifts a richer preview above its menu when you press and hold it.

- Menus
- iOS 18.0+
- beginner
- Origin
- Original work
- Found by
- context menu · long press · preview · card · menu · press and hold · menú contextual · pulsación larga · vista previa · tarjeta · menú · mantener pulsado
Description
Section titled “Description”A card that lifts a richer preview above its menu when long pressed.
The system animates the lift and simplifies it for Reduce Motion. VoiceOver users find the menu items in the actions rotor. Give the preview a fixed size.
private struct ContextMenuPreviewCardDemoTrip: Identifiable { let id: String let subtitle: String let systemImage: String let colors: [Color]}
struct ContextMenuPreviewCardDemo: View { private static let allTrips = [ ContextMenuPreviewCardDemoTrip(id: "Alps", subtitle: "Six days above the clouds", systemImage: "mountain.2.fill", colors: [.teal, .blue]), ContextMenuPreviewCardDemoTrip(id: "Coast", subtitle: "A slow week by the sea", systemImage: "sailboat.fill", colors: [.cyan, .indigo]), ContextMenuPreviewCardDemoTrip(id: "Desert", subtitle: "Stars and quiet nights", systemImage: "sun.max.fill", colors: [.orange, .pink]), ContextMenuPreviewCardDemoTrip(id: "Forest", subtitle: "Trails and cabins", systemImage: "tree.fill", colors: [.green, .mint]), ]
@State private var trips = allTrips @State private var lastAction = "Press and hold a card"
var body: some View { NavigationStack { ScrollView { VStack(alignment: .leading, spacing: 14) { Text(lastAction) .font(.subheadline) .foregroundStyle(.secondary)
LazyVGrid(columns: [GridItem(.adaptive(minimum: 160), spacing: 14)], spacing: 14) { ForEach(trips) { trip in ContextMenuPreviewCard { compactCard(trip) } preview: { richPreview(trip) } menuItems: { Button("Share", systemImage: "square.and.arrow.up") { lastAction = "Shared \(trip.id)" } Button("Save", systemImage: "bookmark") { lastAction = "Saved \(trip.id)" } Button("Remove", systemImage: "trash", role: .destructive) { lastAction = "Removed \(trip.id)" withAnimation { trips.removeAll { $0.id == trip.id } } } } } }
// Only for this demo: a way back once every card is gone. if trips.isEmpty { Button("Start over") { lastAction = "Press and hold a card" withAnimation { trips = Self.allTrips } } .buttonStyle(.borderedProminent) .frame(maxWidth: .infinity) } } .padding(16) } .navigationTitle("Trips") } }
private func compactCard(_ trip: ContextMenuPreviewCardDemoTrip) -> some View { VStack(alignment: .leading, spacing: 4) { Image(systemName: trip.systemImage) .font(.title) .accessibilityHidden(true)
Spacer(minLength: 24)
Text(trip.id).font(.headline) } .foregroundStyle(.white) .padding(16) .frame(maxWidth: .infinity, minHeight: 150, alignment: .leading) .background(LinearGradient(colors: trip.colors, startPoint: .topLeading, endPoint: .bottomTrailing)) .accessibilityElement(children: .combine) }
private func richPreview(_ trip: ContextMenuPreviewCardDemoTrip) -> some View { VStack(alignment: .leading, spacing: 8) { LinearGradient(colors: trip.colors, startPoint: .topLeading, endPoint: .bottomTrailing) .overlay { Image(systemName: trip.systemImage) .font(.largeTitle) .imageScale(.large) .foregroundStyle(.white) } .frame(height: 190)
VStack(alignment: .leading, spacing: 4) { Text(trip.id).font(.title2.bold()) Text(trip.subtitle).font(.body).foregroundStyle(.secondary) } .padding(16) } .frame(width: 300, height: 300, alignment: .top) }}API reference
Section titled “API reference”| Parameter | Type | Default |
|---|---|---|
cornerRadius | CGFloat | 22 |
card | () -> Card | Required |
preview | () -> Preview | Required |
menuItems | () -> MenuItems | Required |
Copy this code into your project, or download the file.
// SPDX-License-Identifier: MIT// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
/// A card that lifts a richer preview above its menu when long pressed.////// The system animates the lift and simplifies it for Reduce Motion. VoiceOver/// users find the menu items in the actions rotor. Give the preview a fixed size.struct ContextMenuPreviewCard<Card: View, Preview: View, MenuItems: View>: View { var cornerRadius: CGFloat = 22 @ViewBuilder var card: () -> Card @ViewBuilder var preview: () -> Preview @ViewBuilder var menuItems: () -> MenuItems
var body: some View { card() .clipShape(.rect(cornerRadius: cornerRadius)) .contentShape(.contextMenuPreview, .rect(cornerRadius: cornerRadius)) .contextMenu(menuItems: menuItems, preview: preview) }}
// MARK: - Demo
private struct ContextMenuPreviewCardDemoTrip: Identifiable { let id: String let subtitle: String let systemImage: String let colors: [Color]}
struct ContextMenuPreviewCardDemo: View { private static let allTrips = [ ContextMenuPreviewCardDemoTrip(id: "Alps", subtitle: "Six days above the clouds", systemImage: "mountain.2.fill", colors: [.teal, .blue]), ContextMenuPreviewCardDemoTrip(id: "Coast", subtitle: "A slow week by the sea", systemImage: "sailboat.fill", colors: [.cyan, .indigo]), ContextMenuPreviewCardDemoTrip(id: "Desert", subtitle: "Stars and quiet nights", systemImage: "sun.max.fill", colors: [.orange, .pink]), ContextMenuPreviewCardDemoTrip(id: "Forest", subtitle: "Trails and cabins", systemImage: "tree.fill", colors: [.green, .mint]), ]
@State private var trips = allTrips @State private var lastAction = "Press and hold a card"
var body: some View { NavigationStack { ScrollView { VStack(alignment: .leading, spacing: 14) { Text(lastAction) .font(.subheadline) .foregroundStyle(.secondary)
LazyVGrid(columns: [GridItem(.adaptive(minimum: 160), spacing: 14)], spacing: 14) { ForEach(trips) { trip in ContextMenuPreviewCard { compactCard(trip) } preview: { richPreview(trip) } menuItems: { Button("Share", systemImage: "square.and.arrow.up") { lastAction = "Shared \(trip.id)" } Button("Save", systemImage: "bookmark") { lastAction = "Saved \(trip.id)" } Button("Remove", systemImage: "trash", role: .destructive) { lastAction = "Removed \(trip.id)" withAnimation { trips.removeAll { $0.id == trip.id } } } } } }
// Only for this demo: a way back once every card is gone. if trips.isEmpty { Button("Start over") { lastAction = "Press and hold a card" withAnimation { trips = Self.allTrips } } .buttonStyle(.borderedProminent) .frame(maxWidth: .infinity) } } .padding(16) } .navigationTitle("Trips") } }
private func compactCard(_ trip: ContextMenuPreviewCardDemoTrip) -> some View { VStack(alignment: .leading, spacing: 4) { Image(systemName: trip.systemImage) .font(.title) .accessibilityHidden(true)
Spacer(minLength: 24)
Text(trip.id).font(.headline) } .foregroundStyle(.white) .padding(16) .frame(maxWidth: .infinity, minHeight: 150, alignment: .leading) .background(LinearGradient(colors: trip.colors, startPoint: .topLeading, endPoint: .bottomTrailing)) .accessibilityElement(children: .combine) }
private func richPreview(_ trip: ContextMenuPreviewCardDemoTrip) -> some View { VStack(alignment: .leading, spacing: 8) { LinearGradient(colors: trip.colors, startPoint: .topLeading, endPoint: .bottomTrailing) .overlay { Image(systemName: trip.systemImage) .font(.largeTitle) .imageScale(.large) .foregroundStyle(.white) } .frame(height: 190)
VStack(alignment: .leading, spacing: 4) { Text(trip.id).font(.title2.bold()) Text(trip.subtitle).font(.body).foregroundStyle(.secondary) } .padding(16) } .frame(width: 300, height: 300, alignment: .top) }}
#Preview { ContextMenuPreviewCardDemo()}