How to Implement Breadcrumb Navigation in SwiftUI
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
-
State array drives everything.
@State private var crumbs: [BreadcrumbItem]is the single source of truth. Adding an item pushes deeper; slicing withArray(crumbs.prefix(selectedIndex + 1))pops back to any ancestor level in one mutation, keeping SwiftUI's diffing efficient. -
Horizontal ScrollView handles depth. Wrapping the
HStackinScrollView(.horizontal, showsIndicators: false)means a 6-level-deep path never overflows on a 375 pt screen — the user simply scrolls left to see ancestors. -
Last crumb is non-interactive. The
isLastcheck renders the final crumb as aText(not aButton) with.primaryforeground style, giving a clear visual affordance that the user is already at this location. -
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. -
Animated transitions. Both push and pop mutations are wrapped in
withAnimation(.easeInOut(duration: 0.2)), which causes theForEachto 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
-
⚠️ iOS 16 foreground style syntax.
.foregroundStyle(.accent)and.tertiaryrequire iOS 17+. On iOS 16 you must fall back to.foregroundColor(.accentColor)— gate behind#if swift(>=5.9)if you still support iOS 16. -
⚠️ ForEach over indices without a stable ID. Using
ForEach(crumbs.indices, id: \.self)is fine for small static-ish lists but will cause unexpected animations if items are reordered. PreferForEach(crumbs)keyed by theBreadcrumbItem.id(UUID) when items can be inserted mid-array. -
⚠️ ScrollView content not scrolling to the newest crumb. After pushing a new item, the scroll position doesn't automatically jump to the trailing edge. Fix with a
ScrollViewReader+.onChange(of: crumbs)callingproxy.scrollTo(crumbs.last?.id, anchor: .trailing)so the new crumb is always visible. -
⚠️ Accessibility: avoid redundant "button" announcements. SwiftUI's
Buttonappends ", button" to VoiceOver reads. Add.accessibilityRemoveTraits(.isButton)only if your design team decides breadcrumb items should be announced as links; otherwise keep the default button trait so keyboard/switch users can activate them.
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?
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?
@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?
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.