```html SwiftUI: How to Implement Breadcrumb Navigation (iOS 17+, 2026)
Soarias

How to Implement Breadcrumb Navigation in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: HStack / ScrollView Updated: May 11, 2026
TL;DR

Build a breadcrumb bar by rendering your navigation path as an array of labeled items inside an HStack wrapped in a horizontal ScrollView. Each crumb fires a callback to pop the stack back to that index.

struct BreadcrumbBar: View {
    let crumbs: [String]
    let onSelect: (Int) -> Void

    var body: some View {
        ScrollView(.horizontal, showsIndicators: false) {
            HStack(spacing: 4) {
                ForEach(crumbs.indices, id: \.self) { i in
                    if i > 0 {
                        Image(systemName: "chevron.right")
                            .font(.caption2)
                            .foregroundStyle(.secondary)
                    }
                    Button(crumbs[i]) { onSelect(i) }
                        .font(.subheadline)
                        .foregroundStyle(
                            i == crumbs.indices.last ? .primary : .accent
                        )
                }
            }
            .padding(.horizontal)
        }
    }
}

Full implementation

The pattern below keeps breadcrumb state in the root view as a @State array of BreadcrumbItem values, which mirrors the app's logical navigation path. Tapping a crumb slices the array back to that position, which simultaneously updates both the breadcrumb bar and the displayed content view. The horizontal ScrollView ensures the bar handles deep hierarchies gracefully on small screens, and the last crumb is rendered in the primary (non-tappable) style to signal the current location.

import SwiftUI

// MARK: - Model

struct BreadcrumbItem: Identifiable, Equatable {
    let id: UUID
    let label: String

    init(_ label: String) {
        self.id = UUID()
        self.label = label
    }
}

// MARK: - Breadcrumb Bar

struct BreadcrumbBar: View {
    let crumbs: [BreadcrumbItem]
    let onSelect: (Int) -> Void

    var body: some View {
        ScrollView(.horizontal, showsIndicators: false) {
            HStack(spacing: 6) {
                ForEach(crumbs.indices, id: \.self) { index in
                    let isLast = index == crumbs.indices.last

                    if index > 0 {
                        Image(systemName: "chevron.right")
                            .font(.caption2.weight(.semibold))
                            .foregroundStyle(.tertiary)
                            .accessibilityHidden(true)
                    }

                    if isLast {
                        Text(crumbs[index].label)
                            .font(.subheadline.weight(.semibold))
                            .foregroundStyle(.primary)
                            .accessibilityAddTraits(.isHeader)
                    } else {
                        Button {
                            onSelect(index)
                        } label: {
                            Text(crumbs[index].label)
                                .font(.subheadline)
                                .foregroundStyle(.accent)
                        }
                        .accessibilityLabel("Go back to \(crumbs[index].label)")
                    }
                }
            }
            .padding(.horizontal, 16)
            .padding(.vertical, 8)
        }
        .background(.bar)
    }
}

// MARK: - Content Placeholder

struct ContentScreen: View {
    let title: String
    let onDrillDown: () -> Void

    var body: some View {
        VStack(spacing: 20) {
            Text(title)
                .font(.largeTitle.bold())
            Button("Drill Down →") {
                onDrillDown()
            }
            .buttonStyle(.borderedProminent)
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}

// MARK: - Root View

struct BreadcrumbDemoView: View {
    @State private var crumbs: [BreadcrumbItem] = [BreadcrumbItem("Home")]

    private var currentTitle: String { crumbs.last?.label ?? "Home" }

    private let childLabels = ["Products", "Electronics", "Laptops", "MacBook Pro"]

    var body: some View {
        VStack(spacing: 0) {
            BreadcrumbBar(crumbs: crumbs) { selectedIndex in
                withAnimation(.easeInOut(duration: 0.2)) {
                    crumbs = Array(crumbs.prefix(selectedIndex + 1))
                }
            }

            Divider()

            ContentScreen(title: currentTitle) {
                let nextIndex = crumbs.count - 1
                guard nextIndex < childLabels.count else { return }
                withAnimation(.easeInOut(duration: 0.2)) {
                    crumbs.append(BreadcrumbItem(childLabels[nextIndex]))
                }
            }
        }
        .navigationTitle("")
        .navigationBarTitleDisplayMode(.inline)
    }
}

// MARK: - Preview

#Preview("Breadcrumb Navigation") {
    NavigationStack {
        BreadcrumbDemoView()
    }
}

How it works

  1. State array drives everything. @State private var crumbs: [BreadcrumbItem] is the single source of truth. Adding an item pushes deeper; slicing with Array(crumbs.prefix(selectedIndex + 1)) pops back to any ancestor level in one mutation, keeping SwiftUI's diffing efficient.
  2. Horizontal ScrollView handles depth. Wrapping the HStack in ScrollView(.horizontal, showsIndicators: false) means a 6-level-deep path never overflows on a 375 pt screen — the user simply scrolls left to see ancestors.
  3. Last crumb is non-interactive. The isLast check renders the final crumb as a Text (not a Button) with .primary foreground style, giving a clear visual affordance that the user is already at this location.
  4. Accessibility labels on back-navigation buttons. Each non-last crumb carries .accessibilityLabel("Go back to \(label)") so VoiceOver users understand the breadcrumb's purpose rather than just hearing the bare label. The chevron images use .accessibilityHidden(true) to reduce noise.
  5. Animated transitions. Both push and pop mutations are wrapped in withAnimation(.easeInOut(duration: 0.2)), which causes the ForEach to insert or remove crumbs with a smooth fade, communicating hierarchy change to the user without jarring cuts.

Variants

Variant 1 — Icon-prefixed crumbs

Pair each BreadcrumbItem with a systemImage name to render a small SF Symbol before the label. Useful for app sections with distinct icons (e.g. cart, profile, settings).

struct BreadcrumbItem: Identifiable, Equatable {
    let id: UUID
    let label: String
    var systemImage: String?

    init(_ label: String, systemImage: String? = nil) {
        self.id = UUID()
        self.label = label
        self.systemImage = systemImage
    }
}

// In BreadcrumbBar, replace the Text label with:
Label {
    Text(crumbs[index].label)
} icon: {
    if let img = crumbs[index].systemImage {
        Image(systemName: img)
            .font(.caption)
    }
}

// Usage:
crumbs = [
    BreadcrumbItem("Home",        systemImage: "house"),
    BreadcrumbItem("Electronics", systemImage: "bolt"),
    BreadcrumbItem("Laptops",     systemImage: "laptopcomputer")
]

Variant 2 — Collapsed "…" for deep paths

When the path exceeds four levels, show only the first crumb, an ellipsis button, and the last two crumbs. Tapping "…" reveals a .confirmationDialog listing hidden ancestors. This keeps the bar compact on small devices while preserving full navigation capability. Implement by computing a var displayedCrumbs computed property that inserts a sentinel BreadcrumbItem("…") when crumbs.count > 4, and handling the tap with @State private var showHidden = false bound to the dialog.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement breadcrumb navigation in SwiftUI for iOS 17+.
Use HStack and ScrollView for the crumb bar layout.
Make it accessible (VoiceOver labels, chevrons hidden from accessibility tree).
Auto-scroll to the newest crumb on push using ScrollViewReader.
Add a #Preview with realistic sample data (Home → Electronics → Laptops → MacBook Pro).

In Soarias's Build phase, paste this prompt directly into the Claude Code panel — it will scaffold the component, wire it into your existing NavigationStack, and generate a matching preview, all without leaving Xcode.

Related guides

FAQ

Does this work on iOS 16?
Mostly — HStack and ScrollView are available on iOS 16. The two gotchas are .foregroundStyle(.accent) (swap for .foregroundColor(.accentColor)) and the #Preview macro (use PreviewProvider on Xcode 14). Everything else compiles cleanly on iOS 16 / Swift 5.7.
How do I sync the breadcrumb bar with a NavigationStack path binding?
Use a shared @State var path: NavigationPath and derive your crumbs array from the same array that backs the path. The cleanest approach is to keep your own [Route] enum array as both the navigationDestination driver and the breadcrumb source, rather than using the type-erased NavigationPath directly (which is opaque and can't be mapped back to labels easily).
What's the UIKit equivalent?
UIKit doesn't have a native breadcrumb component. The closest approach is a UIScrollView (horizontal) containing a programmatically built UIStackView of UIButton and UIImageView (chevron) pairs. SwiftUI's declarative ForEach + HStack pattern is dramatically less boilerplate, which is the primary motivation for migrating this pattern to SwiftUI.

Last reviewed: 2026-05-11 by the Soarias team.

```