A gesture-driven side menu for SwiftUI with native toolbar and navigation support. Inspired by Claude iOS app.
- Smooth, interruptible swipe gestures to open and close
- Full support for SwiftUI
NavigationStackand.toolbarmodifiers - Smart gesture conflict resolution (scroll views, segmented controls, nested navigation)
- Automatic landscape pinning
- Optional
NavigationSplitViewfallback on regular size classes (iPad) - Haptic feedback, rounded corners, shadows — matches iOS system aesthetics
- Zero dependencies · Swift 6.0 · iOS 17+
Add ZSideMenu via Swift Package Manager:
https://github.com/xzebra/ZSideMenu.git
Or in Package.swift:
dependencies: [ .package(url: "https://github.com/xzebra/ZSideMenu.git", from: "1.0.0") ]
import ZSideMenu struct ContentView: View { @State private var isMenuOpen = false var body: some View { ZSideMenuView(isOpen: $isMenuOpen) { // Sidebar List { Button("Home") { isMenuOpen = false } Button("Search") { isMenuOpen = false } } .navigationTitle("Menu") } mainContent: { // Main content NavigationStack { Text("Hello, world!") .navigationTitle("Home") .toolbar { ToolbarItem(placement: .navigation) { Button("Menu", systemImage: "line.horizontal.3") { isMenuOpen.toggle() } } } } } } }
ZSideMenu works seamlessly with SwiftUI's toolbar system. Place toolbars in both the sidebar and the main content — they render natively, exactly as you'd expect.
ZSideMenuView(isOpen: $isMenuOpen) { NavigationStack { List { ForEach(MenuItem.allCases) { item in Label(item.title, systemImage: item.icon) } } .navigationTitle("Menu") .toolbar { ToolbarItemGroup(placement: .bottomBar) { Button("Create", systemImage: "plus") { /* ... */ } Spacer() Button("Settings", systemImage: "gear") { /* ... */ } } } } } mainContent: { // ... }
ZSideMenuView(isOpen: $isMenuOpen) { // sidebar ... } mainContent: { NavigationStack { List { /* ... */ } .navigationTitle("Home") .toolbar { ToolbarItem(placement: .navigation) { Button("Menu", systemImage: "line.horizontal.3") { isMenuOpen.toggle() } } ToolbarItem(placement: .primaryAction) { Button("Add", systemImage: "plus") { /* ... */ } } } } }
Both the sidebar and main content support full NavigationStack navigation with push/pop. The gesture system automatically detects when a view is pushed onto the navigation stack and disables the sidebar swipe, so the system back-swipe works as expected.
ZSideMenuView(isOpen: $isMenuOpen) { // sidebar ... } mainContent: { NavigationStack { List(items) { item in NavigationLink(item.title) { DetailView(item: item) // Back swipe works here — sidebar gesture stays out of the way } } .navigationTitle("Items") .toolbar { ToolbarItem(placement: .navigation) { Button("Menu", systemImage: "line.horizontal.3") { isMenuOpen.toggle() } } } } }
When useNativeSidebar is enabled, ZSideMenu automatically switches to NavigationSplitView on regular-width size classes (iPad, Mac Catalyst), giving users the platform-native experience:
ZSideMenuView( isOpen: $isMenuOpen, useNativeSidebar: true ) { // sidebar ... } mainContent: { // main content ... }
On compact size classes (iPhone), the custom swipe-driven menu is always used.
ZSideMenuView( isOpen: $isMenuOpen, animation: .spring(duration: 0.35, bounce: 0.15) ) { // ... }
ZSideMenuView( isOpen: $isMenuOpen, pinSidebarInLandscape: false ) { // Sidebar will slide in/out even in landscape }
Apply a .background() modifier directly on ZSideMenuView. The sidebar's navigation background is automatically cleared, so your custom background shows through:
ZSideMenuView(isOpen: $isMenuOpen) { SidebarView() } mainContent: { MainView() } .background(Color.gray) // sidebar background
The main content has its own NavigationStack, so set the background inside that stack (e.g. on the root view or via .toolbarBackground):
ZSideMenuView(isOpen: $isMenuOpen) { SidebarView() } mainContent: { NavigationStack { List { /* ... */ } .scrollContentBackground(.hidden) // hide default List background .background(Color.red) // main content background } }
A complete example app is included in the Example/ directory. It demonstrates:
- Tab-based sidebar with selection state
- Toolbar items in both sidebar and main content
- Nested navigation with push/pop
- Custom toolbar title styling
import SwiftUI import ZSideMenu struct ContentView: View { @State private var isMenuOpen = false @State private var selectedTab: MenuItem = .home var body: some View { ZSideMenuView(isOpen: $isMenuOpen) { SidebarView(isMenuOpen: $isMenuOpen, selectedTab: $selectedTab) } mainContent: { MainView(isMenuOpen: $isMenuOpen, selectedTab: $selectedTab) } } } struct SidebarView: View { @Binding var isMenuOpen: Bool @Binding var selectedTab: MenuItem var body: some View { NavigationStack { List { ForEach(MenuItem.allCases) { item in Button { selectedTab = item isMenuOpen = false } label: { Label(item.title, systemImage: item.icon) } } } .navigationTitle("Menu") .toolbar { ToolbarItemGroup(placement: .bottomBar) { Button("Create", systemImage: "plus") { } Spacer() Button("Settings", systemImage: "gear") { } } } } } } struct MainView: View { @Binding var isMenuOpen: Bool @Binding var selectedTab: MenuItem var body: some View { NavigationStack { List { Text("Content for \(selectedTab.title)") } .navigationTitle(selectedTab.title) .toolbar { ToolbarItem(placement: .navigation) { Button("Menu", systemImage: "line.horizontal.3") { isMenuOpen.toggle() } } } } } }
- iOS 17.0+ / macCatalyst 17.0+
- Swift 6.0+
- Xcode 16+
MIT