How to Build a Side Menu in SwiftUI
Overlay a menu panel in a ZStack, then slide it on- and off-screen with .offset(x:) toggled by a @State boolean and a DragGesture for swipe-to-open/close.
struct ContentView: View {
@State private var isOpen = false
let menuWidth: CGFloat = 280
var body: some View {
ZStack(alignment: .leading) {
Color.white.ignoresSafeArea()
Text("Main Content").frame(maxWidth: .infinity, maxHeight: .infinity)
SideMenuPanel()
.frame(width: menuWidth)
.offset(x: isOpen ? 0 : -menuWidth)
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: isOpen)
}
.gesture(
DragGesture()
.onEnded { val in
if val.translation.width > 60 { isOpen = true }
if val.translation.width < -60 { isOpen = false }
}
)
}
}
Full implementation
The approach layers a SideMenuPanel on top of the main content inside a ZStack. A negative x offset hides the panel off-screen to the left; toggling isOpen springs it into view. A semi-transparent overlay dims the main content while the menu is open and provides a tap-to-dismiss target, while a DragGesture on the root view lets users swipe to both open and close the drawer.
import SwiftUI
// MARK: - Menu Items Model
struct MenuItem: Identifiable {
let id = UUID()
let title: String
let systemImage: String
}
// MARK: - Side Menu Panel
struct SideMenuPanel: View {
@Binding var isOpen: Bool
let items: [MenuItem]
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Header
VStack(alignment: .leading, spacing: 4) {
Image(systemName: "person.circle.fill")
.font(.system(size: 52))
.foregroundStyle(.tint)
.accessibilityHidden(true)
Text("Alex Rivera")
.font(.title3).bold()
Text("alex@example.com")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 24)
.padding(.top, 60)
.padding(.bottom, 28)
Divider()
// Navigation items
ScrollView {
VStack(alignment: .leading, spacing: 4) {
ForEach(items) { item in
Button {
isOpen = false
} label: {
Label(item.title, systemImage: item.systemImage)
.font(.body)
.foregroundStyle(.primary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 12)
.padding(.horizontal, 24)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(item.title)
}
}
.padding(.top, 8)
}
Spacer()
}
.frame(maxHeight: .infinity)
.background(.background)
.shadow(color: .black.opacity(0.15), radius: 20, x: 8, y: 0)
}
}
// MARK: - Root View
struct SideMenuDemoView: View {
@State private var isOpen = false
@State private var dragOffset: CGFloat = 0
let menuWidth: CGFloat = 290
let menuItems = [
MenuItem(title: "Home", systemImage: "house"),
MenuItem(title: "Search", systemImage: "magnifyingglass"),
MenuItem(title: "Favorites",systemImage: "heart"),
MenuItem(title: "Settings", systemImage: "gear"),
]
// Clamp offset so the panel never overshoots
private var resolvedOffset: CGFloat {
let base = isOpen ? 0.0 : -menuWidth
return min(0, max(-menuWidth, base + dragOffset))
}
var body: some View {
ZStack(alignment: .leading) {
// Main content
NavigationStack {
List(1..<20) { i in
Text("Row \(i)")
}
.navigationTitle("Dashboard")
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button {
withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) {
isOpen.toggle()
}
} label: {
Image(systemName: isOpen ? "xmark" : "line.3.horizontal")
.accessibilityLabel(isOpen ? "Close menu" : "Open menu")
}
}
}
}
.disabled(isOpen) // prevent interaction while menu is open
// Dim overlay
if isOpen {
Color.black.opacity(0.35)
.ignoresSafeArea()
.onTapGesture {
withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) {
isOpen = false
}
}
.accessibilityLabel("Close menu")
.accessibilityAddTraits(.isButton)
.transition(.opacity)
}
// Side menu panel
SideMenuPanel(isOpen: $isOpen, items: menuItems)
.frame(width: menuWidth)
.offset(x: resolvedOffset)
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: isOpen)
.animation(.interactiveSpring(), value: dragOffset)
}
.gesture(
DragGesture(minimumDistance: 15)
.onChanged { val in
let x = val.translation.width
// Only track drag relevant to current state
if !isOpen && x > 0 { dragOffset = x }
if isOpen && x < 0 { dragOffset = x }
}
.onEnded { val in
withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) {
if val.translation.width > 70 { isOpen = true }
if val.translation.width < -70 { isOpen = false }
dragOffset = 0
}
}
)
}
}
#Preview {
SideMenuDemoView()
}
How it works
-
resolvedOffset— This computed property combines the booleanisOpenstate with a livedragOffset, clamping the result between-menuWidthand0so the panel can never slide past either edge during an active drag. This is what makes the gesture feel physically grounded. -
Dual
.animationmodifiers — The first uses a spring animation driven byisOpenfor the final snap-into-place; the second uses.interactiveSpring()driven bydragOffsetso the panel tracks your finger with very low latency during the drag phase. -
Overlay tap-to-dismiss — The semi-transparent
Color.black.opacity(0.35)view renders only whenisOpenis true and callsisOpen = falseon tap. Its.transition(.opacity)makes the dim fade smoothly in and out alongside the panel. -
DragGesture(minimumDistance: 15)— The 15-point threshold prevents the side-menu drag from fighting with vertical scroll gestures inside theList. The.onChangedhandler filters to only the relevant direction (right when closed, left when open) to avoid jitter. -
Accessibility — The hamburger toolbar button carries an
accessibilityLabelthat updates to "Close menu" when open. The dim overlay has.isButtontrait and an accessible label so VoiceOver users can dismiss the menu without the drag gesture.
Variants
Right-side drawer
Flip the drawer to the trailing edge by changing the ZStack alignment and the offset sign:
ZStack(alignment: .trailing) {
// main content …
SideMenuPanel(isOpen: $isOpen, items: menuItems)
.frame(width: menuWidth)
// Positive offset = off to the right
.offset(x: isOpen ? 0 : menuWidth)
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: isOpen)
}
.gesture(
DragGesture(minimumDistance: 15)
.onEnded { val in
withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) {
if val.translation.width < -70 { isOpen = true }
if val.translation.width > 70 { isOpen = false }
}
}
)
Push-content variant (Instagram-style)
Instead of overlaying the menu, apply the same offset(x:) to the main content view as well — positive when the menu is open — so the content slides right as the menu slides in. Replace the dim overlay with a plain tap-detector. This avoids the shadow-over-content feel and suits apps where users browse menus and content side by side.
Common pitfalls
-
iOS version:
.animation(_:value:)(the value-tied overload) requires iOS 15+, and theNavigationStackrequires iOS 16+. Both are safe on iOS 17+, but if you back-deploy, swapNavigationStackforNavigationViewand remove thevalue:label from the animation modifier. -
Gesture conflict with
NavigationStackback swipe: The system's interactive back gesture and yourDragGesturecan compete when a child view is pushed. Set.gesture(..., including: .gesture)or limit the drag origin to the left 30 pt edge usingDragGesture().sequenced(before:)if you push child views. -
Performance — avoid
withAnimationinside.onChanged: WrappingdragOffsetupdates inwithAnimationduring the live-drag phase creates redundant animation transactions every frame. SetdragOffsetdirectly in.onChangedand only usewithAnimationinside.onEndedfor the final snap.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement a side menu in SwiftUI for iOS 17+. Use offset and DragGesture. Make it accessible (VoiceOver labels for open/close toggle and dim overlay). Add a #Preview with realistic sample data (4-5 navigation items, user profile header).
In the Soarias Build phase, drop this prompt into a feature branch task so the generated component lands in its own file — ready to wire into your app's navigation without touching existing screens.
Related
FAQ
Does this work on iOS 16?
Yes, with two small changes: replace NavigationStack with NavigationView (deprecated in iOS 16 but still functional), and ensure you are not using any toolbar placement values introduced in iOS 17 (such as .topBarLeading — use .navigationBarLeading instead on iOS 16). The offset and DragGesture APIs are available all the way back to iOS 13.
How do I keep the menu open when the device rotates?
The @State private var isOpen boolean survives rotation automatically because SwiftUI preserves @State across geometry changes. However, if you compute menuWidth as a constant, consider making it a fraction of the screen width with GeometryReader (e.g., geometry.size.width * 0.75) so the drawer scales correctly in landscape or on iPad.
What is the UIKit equivalent?
In UIKit you would typically use a container view controller that holds a UIViewController for the menu and one for the content, then animate the content view's transform or the menu's frame using UIView.animate(withDuration:). Many teams reach for SideMenu or DrawerController third-party libraries. The SwiftUI approach with offset and DragGesture is more concise and integrates cleanly with the declarative state model — no subclassing required.
Last reviewed: 2026-05-11 by the Soarias team.