Agent skill

navigation-menus

Modern SwiftUI navigation patterns, NavigationStack, NavigationSplitView, TabView, toolbars, context menus, and router patterns. Use when user asks about navigation, NavigationStack, TabView, menus, toolbars, routing, or deep linking.

Stars 163
Forks 31

Install this agent skill to your Project

npx add-skill https://github.com/majiayu000/claude-skill-registry/tree/main/skills/data/navigation-menus

SKILL.md

SwiftUI Navigation and Menus

Comprehensive guide to modern SwiftUI navigation patterns, menus, toolbars, and routing for iOS 26 and macOS Tahoe development.

Prerequisites

  • iOS 16+ for NavigationStack (iOS 26 recommended)
  • Xcode 26+

NavigationStack (iOS 16+)

Basic NavigationStack

swift
struct ContentView: View {
    var body: some View {
        NavigationStack {
            List(items) { item in
                NavigationLink(item.title) {
                    ItemDetailView(item: item)
                }
            }
            .navigationTitle("Items")
        }
    }
}

Programmatic Navigation with NavigationPath

swift
struct ContentView: View {
    @State private var path = NavigationPath()

    var body: some View {
        NavigationStack(path: $path) {
            List(items) { item in
                Button(item.title) {
                    path.append(item)  // Push programmatically
                }
            }
            .navigationDestination(for: Item.self) { item in
                ItemDetailView(item: item)
            }
            .navigationTitle("Items")
            .toolbar {
                Button("Go to Settings") {
                    path.append(Route.settings)
                }
            }
            .navigationDestination(for: Route.self) { route in
                switch route {
                case .settings:
                    SettingsView()
                case .profile(let userId):
                    ProfileView(userId: userId)
                }
            }
        }
    }
}

enum Route: Hashable {
    case settings
    case profile(userId: String)
}

Navigation Path Operations

swift
// Push a single value
path.append(item)

// Pop one view
path.removeLast()

// Pop multiple views
path.removeLast(2)

// Pop to root
path.removeLast(path.count)

// Clear and push new
path = NavigationPath()
path.append(newRoot)

// Check if empty
if path.isEmpty {
    // At root
}

Type-Safe Navigation with Codable

swift
struct ContentView: View {
    // Codable path for state restoration
    @State private var path = NavigationPath()

    var body: some View {
        NavigationStack(path: $path) {
            RootView()
                .navigationDestination(for: Note.self) { note in
                    NoteDetailView(note: note)
                }
                .navigationDestination(for: Folder.self) { folder in
                    FolderView(folder: folder)
                }
        }
        .onAppear {
            restoreNavigationPath()
        }
        .onChange(of: path) {
            saveNavigationPath()
        }
    }

    func saveNavigationPath() {
        guard let data = try? JSONEncoder().encode(path.codable) else { return }
        UserDefaults.standard.set(data, forKey: "navigationPath")
    }

    func restoreNavigationPath() {
        guard let data = UserDefaults.standard.data(forKey: "navigationPath"),
              let codable = try? JSONDecoder().decode(
                NavigationPath.CodableRepresentation.self,
                from: data
              ) else { return }
        path = NavigationPath(codable)
    }
}

NavigationSplitView

Two-Column Layout

swift
struct TwoColumnView: View {
    @State private var selectedNote: Note?

    var body: some View {
        NavigationSplitView {
            // Sidebar
            List(notes, selection: $selectedNote) { note in
                NavigationLink(value: note) {
                    NoteRow(note: note)
                }
            }
            .navigationTitle("Notes")
        } detail: {
            // Detail
            if let note = selectedNote {
                NoteDetailView(note: note)
            } else {
                ContentUnavailableView(
                    "Select a Note",
                    systemImage: "doc.text",
                    description: Text("Choose a note from the sidebar")
                )
            }
        }
    }
}

Three-Column Layout

swift
struct ThreeColumnView: View {
    @State private var selectedFolder: Folder?
    @State private var selectedNote: Note?

    var body: some View {
        NavigationSplitView {
            // Sidebar
            List(folders, selection: $selectedFolder) { folder in
                NavigationLink(value: folder) {
                    Label(folder.name, systemImage: "folder")
                }
            }
            .navigationTitle("Folders")
        } content: {
            // Content column
            if let folder = selectedFolder {
                List(folder.notes, selection: $selectedNote) { note in
                    NavigationLink(value: note) {
                        Text(note.title)
                    }
                }
                .navigationTitle(folder.name)
            } else {
                ContentUnavailableView("Select a Folder", systemImage: "folder")
            }
        } detail: {
            // Detail column
            if let note = selectedNote {
                NoteDetailView(note: note)
            } else {
                ContentUnavailableView("Select a Note", systemImage: "doc.text")
            }
        }
    }
}

Column Visibility Control

swift
struct AdaptiveNavigationView: View {
    @State private var columnVisibility: NavigationSplitViewVisibility = .all
    @State private var selectedItem: Item?

    var body: some View {
        NavigationSplitView(columnVisibility: $columnVisibility) {
            SidebarView(selection: $selectedItem)
        } detail: {
            DetailView(item: selectedItem)
        }
        .navigationSplitViewStyle(.balanced)  // or .prominentDetail
    }
}

// Visibility options:
// .all - Show all columns
// .doubleColumn - Hide sidebar
// .detailOnly - Show only detail
// .automatic - System decides

NavigationSplitView in Portrait

On iPhone and narrow iPad, NavigationSplitView automatically collapses to NavigationStack behavior with a back button.

swift
NavigationSplitView {
    SidebarView()
} detail: {
    DetailView()
}
.navigationSplitViewStyle(.balanced)

// Style options:
// .automatic - System decides
// .balanced - Equal column importance
// .prominentDetail - Detail takes priority

TabView

Basic TabView

swift
struct MainTabView: View {
    @State private var selectedTab = 0

    var body: some View {
        TabView(selection: $selectedTab) {
            HomeView()
                .tabItem {
                    Label("Home", systemImage: "house")
                }
                .tag(0)

            SearchView()
                .tabItem {
                    Label("Search", systemImage: "magnifyingglass")
                }
                .tag(1)

            ProfileView()
                .tabItem {
                    Label("Profile", systemImage: "person")
                }
                .tag(2)
        }
    }
}

Tab with Badge

swift
TabView {
    NotificationsView()
        .tabItem {
            Label("Notifications", systemImage: "bell")
        }
        .badge(unreadCount)  // Shows badge number
        .badge("New")        // Shows text badge
}

iOS 26 Search Tab Role

swift
TabView {
    HomeView()
        .tabItem {
            Label("Home", systemImage: "house")
        }

    // Search tab morphs into search field when tapped
    SearchResultsView()
        .tabItem {
            Label("Search", systemImage: "magnifyingglass")
        }
        .tabItemRole(.search)  // iOS 26 - Morphs to search field
}

TabView with NavigationStack

Each tab should have its own NavigationStack:

swift
struct MainTabView: View {
    @State private var selectedTab = 0
    @State private var homePath = NavigationPath()
    @State private var searchPath = NavigationPath()

    var body: some View {
        TabView(selection: $selectedTab) {
            NavigationStack(path: $homePath) {
                HomeView()
                    .navigationDestination(for: Item.self) { item in
                        ItemDetailView(item: item)
                    }
            }
            .tabItem {
                Label("Home", systemImage: "house")
            }
            .tag(0)

            NavigationStack(path: $searchPath) {
                SearchView()
                    .navigationDestination(for: SearchResult.self) { result in
                        SearchResultDetailView(result: result)
                    }
            }
            .tabItem {
                Label("Search", systemImage: "magnifyingglass")
            }
            .tag(1)
        }
    }
}

Programmatic Tab Switching

swift
struct TabCoordinator: View {
    @State private var selectedTab = Tab.home

    enum Tab: Hashable {
        case home, search, profile
    }

    var body: some View {
        TabView(selection: $selectedTab) {
            HomeView(switchTab: { selectedTab = $0 })
                .tabItem { Label("Home", systemImage: "house") }
                .tag(Tab.home)

            SearchView()
                .tabItem { Label("Search", systemImage: "magnifyingglass") }
                .tag(Tab.search)

            ProfileView()
                .tabItem { Label("Profile", systemImage: "person") }
                .tag(Tab.profile)
        }
    }
}

Toolbars

Basic Toolbar

swift
struct ContentView: View {
    var body: some View {
        NavigationStack {
            ContentList()
                .navigationTitle("Items")
                .toolbar {
                    Button("Add", systemImage: "plus") {
                        addItem()
                    }
                }
        }
    }
}

Toolbar Placements

swift
.toolbar {
    // Leading position (left on LTR)
    ToolbarItem(placement: .topBarLeading) {
        Button("Edit") { }
    }

    // Trailing position (right on LTR)
    ToolbarItem(placement: .topBarTrailing) {
        Button("Done") { }
    }

    // Primary action (system decides best position)
    ToolbarItem(placement: .primaryAction) {
        Button("Save") { }
    }

    // Secondary action
    ToolbarItem(placement: .secondaryAction) {
        Button("Settings") { }
    }

    // Cancel/dismiss action
    ToolbarItem(placement: .cancellationAction) {
        Button("Cancel") { }
    }

    // Confirmation action
    ToolbarItem(placement: .confirmationAction) {
        Button("Confirm") { }
    }

    // Destructive action
    ToolbarItem(placement: .destructiveAction) {
        Button("Delete", role: .destructive) { }
    }

    // Bottom bar
    ToolbarItem(placement: .bottomBar) {
        Button("Action") { }
    }
}

ToolbarItemGroup

swift
.toolbar {
    ToolbarItemGroup(placement: .topBarTrailing) {
        Button("Share", systemImage: "square.and.arrow.up") { }
        Button("More", systemImage: "ellipsis.circle") { }
    }
}

iOS 26 ToolbarSpacer

swift
.toolbar {
    ToolbarItem(placement: .topBarTrailing) {
        Button("Edit") { }
    }

    // Fixed spacer - groups related items
    ToolbarSpacer(.fixed)

    ToolbarItem(placement: .topBarTrailing) {
        Button("Add", systemImage: "plus") { }
    }

    // Flexible spacer - expands
    ToolbarSpacer(.flexible)

    ToolbarItem(placement: .topBarTrailing) {
        Button("Settings", systemImage: "gear") { }
    }
}

iOS 26 Close Button Role

swift
.toolbar {
    ToolbarItem(placement: .cancellationAction) {
        Button("Dismiss", role: .close) {
            dismiss()
        }
        // Renders as glass X button in iOS 26
    }
}

Toolbar Background with Liquid Glass

swift
.toolbarBackgroundVisibility(.visible, for: .navigationBar)
.toolbarBackground(.ultraThinMaterial, for: .navigationBar)

// iOS 26 - Glass toolbar
.toolbarBackground(.glass, for: .navigationBar)

Custom Toolbar Content

swift
.toolbar {
    ToolbarItem(placement: .principal) {
        VStack {
            Text("Title")
                .font(.headline)
            Text("Subtitle")
                .font(.caption)
                .foregroundStyle(.secondary)
        }
    }
}

Context Menus

Basic Context Menu

swift
struct ItemRow: View {
    let item: Item

    var body: some View {
        Text(item.title)
            .contextMenu {
                Button("Copy") {
                    copyItem()
                }
                Button("Share") {
                    shareItem()
                }
                Divider()
                Button("Delete", role: .destructive) {
                    deleteItem()
                }
            }
    }
}

Context Menu with Preview

swift
Text(item.title)
    .contextMenu {
        Button("Open") { }
        Button("Share") { }
    } preview: {
        ItemPreviewView(item: item)
            .frame(width: 300, height: 200)
    }

Conditional Menu Items

swift
.contextMenu {
    if item.isFavorite {
        Button("Remove from Favorites") {
            toggleFavorite()
        }
    } else {
        Button("Add to Favorites") {
            toggleFavorite()
        }
    }

    if canEdit {
        Button("Edit") { }
    }
}

Context Menu on Lists

swift
List(items) { item in
    ItemRow(item: item)
        .contextMenu {
            Button("Edit") { editItem(item) }
            Button("Delete", role: .destructive) { deleteItem(item) }
        }
}

Menus

Menu Button

swift
Menu {
    Button("Option 1") { }
    Button("Option 2") { }
    Button("Option 3") { }
} label: {
    Label("Menu", systemImage: "ellipsis.circle")
}

Nested Menus

swift
Menu {
    Button("Quick Action") { }

    Menu("Sort By") {
        Button("Name") { }
        Button("Date") { }
        Button("Size") { }
    }

    Menu("Filter") {
        Button("All") { }
        Button("Recent") { }
        Button("Favorites") { }
    }

    Divider()

    Button("Settings") { }
} label: {
    Image(systemName: "ellipsis.circle")
}

Menu with Selection

swift
@State private var sortOrder = SortOrder.name

Menu {
    Picker("Sort", selection: $sortOrder) {
        ForEach(SortOrder.allCases) { order in
            Text(order.displayName).tag(order)
        }
    }
} label: {
    Label("Sort", systemImage: "arrow.up.arrow.down")
}

Primary Action Menu

swift
Menu {
    Button("Edit") { }
    Button("Duplicate") { }
    Button("Delete", role: .destructive) { }
} label: {
    Label("Actions", systemImage: "ellipsis.circle")
} primaryAction: {
    // Primary tap action
    openItem()
}

iPad Menu Bar (iOS 26)

Adding Commands

swift
@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .commands {
            // File menu
            CommandGroup(replacing: .newItem) {
                Button("New Document") {
                    createNewDocument()
                }
                .keyboardShortcut("n", modifiers: .command)
            }

            // Edit menu additions
            CommandGroup(after: .pasteboard) {
                Button("Duplicate") {
                    duplicateSelected()
                }
                .keyboardShortcut("d", modifiers: .command)
            }

            // Custom menu
            CommandMenu("View") {
                Button("Show Sidebar") {
                    showSidebar()
                }
                .keyboardShortcut("s", modifiers: [.command, .control])

                Toggle("Dark Mode", isOn: $isDarkMode)
            }
        }
    }
}

Command Groups

swift
.commands {
    // Replace existing group
    CommandGroup(replacing: .help) {
        Button("My App Help") { }
    }

    // Add before group
    CommandGroup(before: .sidebar) {
        Button("Toggle Inspector") { }
    }

    // Add after group
    CommandGroup(after: .toolbar) {
        Divider()
        Button("Reset Layout") { }
    }
}

Searchable

Basic Search

swift
struct SearchableView: View {
    @State private var searchText = ""

    var filteredItems: [Item] {
        if searchText.isEmpty {
            return items
        }
        return items.filter { $0.title.localizedCaseInsensitiveContains(searchText) }
    }

    var body: some View {
        NavigationStack {
            List(filteredItems) { item in
                ItemRow(item: item)
            }
            .searchable(text: $searchText, prompt: "Search items")
            .navigationTitle("Items")
        }
    }
}

Search with Suggestions

swift
.searchable(text: $searchText) {
    ForEach(suggestions) { suggestion in
        Text(suggestion.title)
            .searchCompletion(suggestion.title)
    }
}

Search Scopes

swift
@State private var searchScope = SearchScope.all

.searchable(text: $searchText)
.searchScopes($searchScope) {
    Text("All").tag(SearchScope.all)
    Text("Recent").tag(SearchScope.recent)
    Text("Favorites").tag(SearchScope.favorites)
}

iOS 26 Search Placement

swift
// NavigationStack - search in navigation bar
NavigationStack {
    ContentView()
        .searchable(text: $searchText)
}

// NavigationSplitView - search in sidebar
NavigationSplitView {
    SidebarView()
        .searchable(text: $searchText)
} detail: {
    DetailView()
}

Search Tokens

swift
@State private var tokens: [SearchToken] = []

.searchable(text: $searchText, tokens: $tokens) { token in
    Label(token.name, systemImage: token.icon)
}
.searchSuggestions {
    ForEach(suggestedTokens) { token in
        Label(token.name, systemImage: token.icon)
            .searchCompletion(token)
    }
}

Router/Coordinator Pattern

Central App Router

swift
@Observable
class AppRouter {
    var homePath = NavigationPath()
    var searchPath = NavigationPath()
    var profilePath = NavigationPath()

    var selectedTab: Tab = .home

    enum Tab: Hashable {
        case home, search, profile
    }

    // MARK: - Navigation Actions

    func navigateToItem(_ item: Item) {
        switch selectedTab {
        case .home:
            homePath.append(item)
        case .search:
            searchPath.append(item)
        case .profile:
            profilePath.append(item)
        }
    }

    func navigateToProfile(userId: String) {
        selectedTab = .profile
        profilePath.append(ProfileDestination.user(userId))
    }

    func popToRoot() {
        switch selectedTab {
        case .home:
            homePath.removeLast(homePath.count)
        case .search:
            searchPath.removeLast(searchPath.count)
        case .profile:
            profilePath.removeLast(profilePath.count)
        }
    }

    func pop() {
        switch selectedTab {
        case .home where !homePath.isEmpty:
            homePath.removeLast()
        case .search where !searchPath.isEmpty:
            searchPath.removeLast()
        case .profile where !profilePath.isEmpty:
            profilePath.removeLast()
        default:
            break
        }
    }
}

Router Usage

swift
@main
struct MyApp: App {
    @State private var router = AppRouter()

    var body: some Scene {
        WindowGroup {
            RootView()
                .environment(router)
        }
    }
}

struct RootView: View {
    @Environment(AppRouter.self) var router

    var body: some View {
        @Bindable var router = router

        TabView(selection: $router.selectedTab) {
            NavigationStack(path: $router.homePath) {
                HomeView()
                    .navigationDestination(for: Item.self) { item in
                        ItemDetailView(item: item)
                    }
            }
            .tabItem { Label("Home", systemImage: "house") }
            .tag(AppRouter.Tab.home)

            NavigationStack(path: $router.searchPath) {
                SearchView()
                    .navigationDestination(for: Item.self) { item in
                        ItemDetailView(item: item)
                    }
            }
            .tabItem { Label("Search", systemImage: "magnifyingglass") }
            .tag(AppRouter.Tab.search)

            NavigationStack(path: $router.profilePath) {
                ProfileView()
                    .navigationDestination(for: ProfileDestination.self) { dest in
                        ProfileDestinationView(destination: dest)
                    }
            }
            .tabItem { Label("Profile", systemImage: "person") }
            .tag(AppRouter.Tab.profile)
        }
    }
}

Using Router in Child Views

swift
struct ItemRow: View {
    @Environment(AppRouter.self) var router
    let item: Item

    var body: some View {
        Button(item.title) {
            router.navigateToItem(item)
        }
    }
}

Deep Linking

URL Handling

swift
@main
struct MyApp: App {
    @State private var router = AppRouter()

    var body: some Scene {
        WindowGroup {
            RootView()
                .environment(router)
                .onOpenURL { url in
                    handleDeepLink(url)
                }
        }
    }

    func handleDeepLink(_ url: URL) {
        guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
            return
        }

        // myapp://item/123
        // myapp://profile/user456

        let pathComponents = components.path.split(separator: "/")

        switch pathComponents.first {
        case "item":
            if let idString = pathComponents.dropFirst().first,
               let id = UUID(uuidString: String(idString)) {
                router.selectedTab = .home
                router.homePath.append(ItemDestination(id: id))
            }
        case "profile":
            if let userId = pathComponents.dropFirst().first {
                router.navigateToProfile(userId: String(userId))
            }
        default:
            break
        }
    }
}

Universal Links

swift
// In Info.plist, add Associated Domains capability
// applinks:yourdomain.com

// Handle in onOpenURL
.onOpenURL { url in
    // https://yourdomain.com/item/123
    if url.host == "yourdomain.com" {
        handleUniversalLink(url)
    }
}

Sheets and Presentations

Basic Sheet

swift
struct ContentView: View {
    @State private var showingSheet = false

    var body: some View {
        Button("Show Sheet") {
            showingSheet = true
        }
        .sheet(isPresented: $showingSheet) {
            SheetContent()
        }
    }
}

Sheet with Item

swift
@State private var selectedItem: Item?

List(items) { item in
    Button(item.title) {
        selectedItem = item
    }
}
.sheet(item: $selectedItem) { item in
    ItemDetailSheet(item: item)
}

Presentation Detents

swift
.sheet(isPresented: $showingSheet) {
    SheetContent()
        .presentationDetents([.medium, .large])
        .presentationDragIndicator(.visible)
}

// Custom detent
.presentationDetents([
    .height(200),
    .fraction(0.4),
    .medium,
    .large
])

Sheet Customization

swift
.sheet(isPresented: $showingSheet) {
    SheetContent()
        .presentationDetents([.medium, .large])
        .presentationDragIndicator(.visible)
        .presentationCornerRadius(20)
        .presentationBackground(.thinMaterial)
        .presentationContentInteraction(.scrolls)
        .interactiveDismissDisabled(hasUnsavedChanges)
}

Full Screen Cover

swift
.fullScreenCover(isPresented: $showingFullScreen) {
    FullScreenView()
}

Alerts and Confirmations

Basic Alert

swift
@State private var showingAlert = false

Button("Delete") {
    showingAlert = true
}
.alert("Delete Item?", isPresented: $showingAlert) {
    Button("Cancel", role: .cancel) { }
    Button("Delete", role: .destructive) {
        deleteItem()
    }
} message: {
    Text("This action cannot be undone.")
}

Alert with Data

swift
@State private var itemToDelete: Item?

.alert("Delete Item?", isPresented: .constant(itemToDelete != nil), presenting: itemToDelete) { item in
    Button("Cancel", role: .cancel) {
        itemToDelete = nil
    }
    Button("Delete", role: .destructive) {
        delete(item)
        itemToDelete = nil
    }
} message: { item in
    Text("Are you sure you want to delete \"\(item.title)\"?")
}

Confirmation Dialog

swift
@State private var showingConfirmation = false

.confirmationDialog("Choose Action", isPresented: $showingConfirmation) {
    Button("Share") { share() }
    Button("Duplicate") { duplicate() }
    Button("Delete", role: .destructive) { delete() }
    Button("Cancel", role: .cancel) { }
} message: {
    Text("What would you like to do with this item?")
}

Best Practices

1. Place NavigationStack at Root

swift
// GOOD: NavigationStack wraps content
NavigationStack {
    TabView {
        // Content
    }
}

// BETTER: Each tab has its own NavigationStack
TabView {
    NavigationStack {
        HomeView()
    }
    .tabItem { ... }
}

2. Use Type-Safe Navigation

swift
// GOOD: Type-safe destinations
.navigationDestination(for: Item.self) { item in
    ItemDetailView(item: item)
}

// AVOID: String-based or untyped navigation

3. Keep Navigation State Observable

swift
// Centralize navigation state
@Observable
class Router {
    var path = NavigationPath()
}

// Inject via environment
.environment(router)

4. Handle Empty States

swift
NavigationSplitView {
    SidebarView()
} detail: {
    if let selected = selectedItem {
        DetailView(item: selected)
    } else {
        ContentUnavailableView(
            "No Selection",
            systemImage: "doc",
            description: Text("Select an item to view details")
        )
    }
}

5. Support Deep Linking

swift
// Always handle URL schemes
.onOpenURL { url in
    handleDeepLink(url)
}

Official Resources

Expand your agent's capabilities with these related and highly-rated skills.

Didn't find tool you were looking for?

Be as detailed as possible for better results