SwiftUI: Create a Custom Navigation Bar
In this article we create a reusable navigation bar in SwiftUI: a centred or large title, leading and trailing buttons, your own colours, and a Back button that actually pops the screen. It's applied as a modifier, so it sits where the system bar would.
When to use it
A custom bar replaces the system navigation bar, and hiding the system bar also turns off swipe to go back. The Back button keeps working, but the swipe doesn’t.
That makes it a good fit for sheets, full-screen covers and the root screen of a stack, where there is nothing to swipe back to. If a pushed screen only needs a custom title, your own buttons or a background colour, the system bar can already do that and keeps the swipe:
List(items) { item in Text(item.name) } .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .principal) { Text("Title") .font(.system(.headline, design: .rounded)) } ToolbarItem(placement: .topBarTrailing) { Button("Save") {} } } .toolbarBackground(.black, for: .navigationBar) .toolbarBackground(.visible, for: .navigationBar) .toolbarColorScheme(.dark, for: .navigationBar)
The component
One file, no dependencies, iOS 17 and later:
// // CustomNavigationBar.swift // // A custom top bar for SwiftUI screens. // // Reach for .navigationTitle + .toolbar first. Hiding the system bar also // switches off swipe-to-go-back, and there is no public API to get it back, // so this is best on sheets and on the root of a stack. // // Article: https://dipinkrishna.com/blog/2023/08/swiftui-create-a-custom-navigation-bar/ // Requires iOS 17. // import SwiftUI enum CustomNavigationBarTitleDisplayMode { case inline case large } extension View { /// Hides the system navigation bar and pins a custom one to the top safe area. /// /// On a pushed screen the Back button still works, but swiping back does /// not - UIKit turns the gesture off whenever the bar is hidden. func customNavigationBar<Title: View, Leading: View, Trailing: View>( titleDisplayMode: CustomNavigationBarTitleDisplayMode = .inline, foreground: Color = .primary, background: some ShapeStyle = .bar, @ViewBuilder title: () -> Title, @ViewBuilder leading: () -> Leading = { NavigationBackButton() }, @ViewBuilder trailing: () -> Trailing = { EmptyView() } ) -> some View { toolbar(.hidden, for: .navigationBar) .safeAreaInset(edge: .top, spacing: 0) { CustomNavigationBar( titleDisplayMode: titleDisplayMode, title: title, leading: leading, trailing: trailing ) .foregroundStyle(foreground) .tint(foreground) .background(background, ignoresSafeAreaEdges: .top) } } } struct CustomNavigationBar<Title: View, Leading: View, Trailing: View>: View { var titleDisplayMode: CustomNavigationBarTitleDisplayMode = .inline @ViewBuilder var title: Title @ViewBuilder var leading: Leading @ViewBuilder var trailing: Trailing var body: some View { VStack(alignment: .leading, spacing: 0) { BarLayout { HStack(spacing: 16) { leading } .layoutValue(key: BarSlotKey.self, value: .leading) if titleDisplayMode == .inline { title .font(.headline) .lineLimit(1) .accessibilityAddTraits(.isHeader) .layoutValue(key: BarSlotKey.self, value: .title) } HStack(spacing: 16) { trailing } .layoutValue(key: BarSlotKey.self, value: .trailing) } .frame(minHeight: 44) if titleDisplayMode == .large { title .font(.largeTitle.bold()) .accessibilityAddTraits(.isHeader) .padding(.bottom, 8) } } .padding(.horizontal) } } /// Pops the current screen. Renders nothing on the root of a stack. struct NavigationBackButton: View { @Environment(\.isPresented) private var isPresented @Environment(\.dismiss) private var dismiss var body: some View { if isPresented { Button { dismiss() } label: { // At large text sizes, drop the word and keep the chevron. ViewThatFits(in: .horizontal) { HStack(spacing: 4) { chevron Text("Back").lineLimit(1) } chevron } } .accessibilityLabel("Back") } } private var chevron: some View { Image(systemName: "chevron.backward").fontWeight(.semibold) } } private enum BarSlot { case leading, title, trailing } private struct BarSlotKey: LayoutValueKey { static let defaultValue = BarSlot.title } /// Leading and trailing hug the edges. The title stays centred on the bar /// and truncates before it can run into either side. private struct BarLayout: Layout { var spacing: CGFloat = 8 func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { let bar = measure(width: proposal.width, subviews: subviews) return CGSize(width: bar.width, height: bar.height) } func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { let bar = measure(width: bounds.width, subviews: subviews) for subview in subviews { let slot = subview[BarSlotKey.self] let (x, anchor): (CGFloat, UnitPoint) = switch slot { case .leading: (bounds.minX, .leading) case .title: (bounds.midX, .center) case .trailing: (bounds.maxX, .trailing) } subview.place( at: CGPoint(x: x, y: bounds.midY), anchor: anchor, proposal: ProposedViewSize(bar.sizes[slot] ?? .zero) ) } } private func measure(width: CGFloat?, subviews: Subviews) -> (sizes: [BarSlot: CGSize], width: CGFloat, height: CGFloat) { func size(of slot: BarSlot, maxWidth: CGFloat) -> CGSize { subviews.first { $0[BarSlotKey.self] == slot }? .sizeThatFits(ProposedViewSize(width: maxWidth, height: nil)) ?? .zero } let available = width ?? .infinity // Each side may use up to a third of the bar... let leading = size(of: .leading, maxWidth: available / 3) let trailing = size(of: .trailing, maxWidth: available / 3) // ...and the title gets what is left after reserving the wider side on // *both* edges. That reservation is what keeps it centred. let side = max(leading.width, trailing.width) let title = size(of: .title, maxWidth: max(0, available - 2 * (side + spacing))) return ( [.leading: leading, .title: title, .trailing: trailing], width ?? 2 * (side + spacing) + title.width, max(leading.height, title.height, trailing.height) ) } } #Preview("Sheet header") { Text("Behind the sheet") .sheet(isPresented: .constant(true)) { List(1...30, id: \.self) { row in Text("Row \(row)") } .customNavigationBar( titleDisplayMode: .large, foreground: .white, background: .green ) { Text("Title") } leading: { EmptyView() } trailing: { Button("Done") {} } } }
How it works
It’s a modifier. customNavigationBar hides the system bar and pins the custom one with .safeAreaInset(edge: .top, spacing: 0). The screen’s content is laid out below the bar and scrolls underneath it, the same as with the system bar.
The background reaches the top of the screen. .background(_:ignoresSafeAreaEdges: .top) fills in behind the status bar. The default background is the .bar material the system uses, and the default foreground is .primary.
The slots are generic. Title, leading and trailing are @ViewBuilder closures, so any view goes in them without AnyView. Pass EmptyView() for a slot you don’t want.
The Back button knows where it is. NavigationBackButton reads @Environment(\.isPresented) and calls dismiss(). It pops a pushed screen and draws nothing on the root of a stack. At large text sizes ViewThatFits drops the word and keeps the chevron.
The title stays centred. BarLayout is a custom Layout. Each side gets at most a third of the width, and the title gets what’s left after the wider side is reserved on both edges. A short button on one side and a long one on the other don’t pull the title off centre, and a long title truncates instead of running into the buttons.
The height follows the text. The bar is at least 44 points tall and grows with larger text sizes.

Using it
An inline title on a black bar, with a Save button on the right and the default Back button on the left:
EditorView() .customNavigationBar(foreground: .white, background: .black) { Text("Title") } trailing: { Button("Save") {} }
A large title under the Back button:
ProfileView() .customNavigationBar( titleDisplayMode: .large, foreground: .white, background: .blue ) { Text("Title") }
A sheet header with a close button. In a sheet, replace the default Back button with nothing, or with your own button:
.sheet(isPresented: $showingEditor) { NoteEditor() .customNavigationBar( titleDisplayMode: .large, foreground: .white, background: .green ) { Text("New Note") } leading: { EmptyView() } trailing: { Button { showingEditor = false } label: { Image(systemName: "xmark.circle") .font(.title) } .accessibilityLabel("Close") } }
You can download the file from GitHub: CustomNavigationBar.swift
Hope it helps!