How to implement drawer navigation in SwiftUI
Layer your main content and a drawer panel inside a ZStack, then toggle
.offset(x:) with withAnimation to slide the drawer on and off screen.
A DragGesture wires up the natural swipe-to-open feel.
struct DrawerLayout: View {
@State private var isOpen = false
let drawerWidth: CGFloat = 280
var body: some View {
ZStack(alignment: .leading) {
Color.white.ignoresSafeArea() // main content placeholder
if isOpen {
Color.black.opacity(0.35)
.ignoresSafeArea()
.onTapGesture { withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { isOpen = false } }
}
DrawerPanel()
.frame(width: drawerWidth)
.offset(x: isOpen ? 0 : -drawerWidth)
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: isOpen)
}
}
}
Full implementation
The complete example below builds a self-contained drawer shell: a DrawerLayout
view owns all the state and gesture logic, while DrawerPanel and
MainContent are pure display views that receive a binding or closure to request
open/close. The drag gesture tracks translation in real time so the drawer follows the user's
finger, snapping open or closed on release based on a velocity/distance threshold.
import SwiftUI
// MARK: - Drawer Shell
struct DrawerLayout: View {
@State private var isOpen = false
@State private var dragOffset: CGFloat = 0
let drawerWidth: CGFloat = 280
let snapThreshold: CGFloat = 80 // px or velocity threshold
private var resolvedOffset: CGFloat {
let base = isOpen ? 0 : -drawerWidth
return min(0, base + dragOffset) // clamp: never slides right of 0
}
var body: some View {
ZStack(alignment: .leading) {
// ── Main content ──────────────────────────────────
MainContent(openDrawer: { withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { isOpen = true } })
.frame(maxWidth: .infinity, maxHeight: .infinity)
// ── Dimming overlay ───────────────────────────────
if isOpen || dragOffset > 0 {
let progress = min(1, (resolvedOffset + drawerWidth) / drawerWidth)
Color.black
.opacity(0.45 * progress)
.ignoresSafeArea()
.onTapGesture {
withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) {
isOpen = false
}
}
}
// ── Drawer panel ──────────────────────────────────
DrawerPanel(close: {
withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { isOpen = false }
})
.frame(width: drawerWidth)
.offset(x: resolvedOffset)
.gesture(
DragGesture()
.onChanged { value in
dragOffset = value.translation.width
}
.onEnded { value in
let velocity = value.predictedEndTranslation.width - value.translation.width
let shouldClose = value.translation.width < -snapThreshold || velocity < -200
withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) {
isOpen = shouldClose ? false : true
dragOffset = 0
}
}
)
}
// Edge swipe to open from the leading edge
.gesture(
DragGesture()
.onChanged { value in
guard value.startLocation.x < 20 else { return }
dragOffset = value.translation.width
}
.onEnded { value in
guard value.startLocation.x < 20 else { return }
let shouldOpen = value.translation.width > snapThreshold
withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) {
isOpen = shouldOpen
dragOffset = 0
}
}
)
}
}
// MARK: - Drawer Panel
struct DrawerPanel: View {
let close: () -> Void
private let items = ["Home", "Profile", "Settings", "Help", "About"]
var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack {
Text("Menu")
.font(.title2.bold())
Spacer()
Button(action: close) {
Image(systemName: "xmark")
.font(.body.weight(.semibold))
.foregroundStyle(.secondary)
}
.accessibilityLabel("Close drawer")
}
.padding(.horizontal, 20)
.padding(.top, 60)
.padding(.bottom, 24)
ForEach(items, id: \.self) { item in
Button {
close()
} label: {
Text(item)
.font(.body)
.foregroundStyle(.primary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 14)
.padding(.horizontal, 20)
}
.accessibilityLabel("\(item), drawer item")
Divider().padding(.leading, 20)
}
Spacer()
}
.background(.white)
.ignoresSafeArea(edges: .vertical)
}
}
// MARK: - Main Content Placeholder
struct MainContent: View {
let openDrawer: () -> Void
var body: some View {
NavigationStack {
Text("Main content goes here")
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.systemGroupedBackground))
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button(action: openDrawer) {
Image(systemName: "line.3.horizontal")
}
.accessibilityLabel("Open navigation drawer")
}
}
.navigationTitle("My App")
}
}
}
// MARK: - Preview
#Preview {
DrawerLayout()
}
How it works
-
ZStack alignment drives layering.
ZStack(alignment: .leading)keeps all three layers — main content, dim overlay, drawer — anchored to the leading edge so.offset(x:)slides the panel relative to that anchor rather than the screen center. -
resolvedOffsetmerges state + live drag. Instead of two separate.offsetcalls, a computed property combines the base position (isOpen ? 0 : -drawerWidth) with the in-flightdragOffset. Themin(0, …)clamp prevents the drawer from over-extending to the right. -
Dim opacity tracks progress linearly.
progress = (resolvedOffset + drawerWidth) / drawerWidthmaps 0 → 1 as the drawer travels, so the backdrop fades in sync rather than snapping on at the end of the animation. -
Spring animation for snap.
.spring(response: 0.35, dampingFraction: 0.8)on both thewithAnimationcall and the.animation(_:value:)modifier ensures the drawer decelerates naturally without overshooting. AdjustingdampingFractionlower (e.g. 0.6) adds a subtle bounce. -
Edge-swipe gesture to open. The outer
DragGestureonDrawerLayoutguards withvalue.startLocation.x < 20, so only gestures beginning at the very leading edge trigger the open action — preventing conflicts with horizontal scrollers in the main content area.
Variants
Trailing (right-side) drawer
// Change ZStack alignment and flip the offset sign
ZStack(alignment: .trailing) {
MainContent(openDrawer: { isOpen = true })
if isOpen {
Color.black.opacity(0.35)
.ignoresSafeArea()
.onTapGesture { withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { isOpen = false } }
}
DrawerPanel(close: { withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { isOpen = false } })
.frame(width: drawerWidth)
.offset(x: isOpen ? 0 : drawerWidth) // ← positive offset pushes right off screen
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: isOpen)
}
Push-content style (main content slides with drawer)
Instead of overlaying, apply a matching .offset(x: isOpen ? drawerWidth : 0) to
MainContent so the two panels move together. Combine with
.scaleEffect(isOpen ? 0.92 : 1) on the main content for a modern "push" feel
popularised by many iOS apps. Keep the spring parameters identical on both views so they travel
in lockstep. Note this approach works best when drawerWidth is less than ~60% of
screen width; otherwise the main content disappears too far off the trailing edge on smaller
devices.
Common pitfalls
-
iOS version: The
.animation(_:value:)overload that takes anEquatablevalue was introduced in iOS 15, but thewithAnimationclosure style works back to iOS 13. Both are safe on iOS 17+, but avoid the deprecated.animation(_:)modifier without a value binding — SwiftUI 5 can animate unintended views with it. -
Gesture conflicts: If your main content contains a
ScrollViewor aListwith horizontal swipe actions, SwiftUI's simultaneous gesture recogniser may steal the edge-swipe. Wrap the main content in a.simultaneousGestureor usehighPriorityGestureon the edge zone to give the drawer gesture precedence. -
Accessibility: A drawer that only opens via swipe is unusable with Switch
Control. Always expose a visible toolbar button (
accessibilityLabel("Open navigation drawer")) and add.accessibilityHidden(true)to the dim overlay so VoiceOver skips it and lands on the drawer's first focusable item automatically.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement drawer navigation in SwiftUI for iOS 17+. Use offset and Animation (withAnimation + .spring). Make it accessible (VoiceOver labels on open/close buttons, accessibilityHidden on the dim overlay). Support both tap-overlay-to-close and drag-to-close gestures. Add a #Preview with realistic sample data (nav items with SF Symbols).
In Soarias's Build phase, drop this prompt into the component scaffold step so the drawer shell is generated alongside your NavigationStack screens — keeping routing state in one place from the start.
Related
FAQ
Does this work on iOS 16?
Yes — .offset(x:) and withAnimation(.spring(…)) are available
from iOS 13. The only iOS 17-specific feature used in the full example is the
#Preview macro; swap it for a PreviewProvider conformance if you
need iOS 16 support. Everything else compiles and runs unchanged.
How do I persist the selected drawer item across app launches?
Store the selected route in an @AppStorage variable (backed by
UserDefaults) or in a @Observable router class injected via
.environment(). Pass the selection into DrawerPanel as a binding
so tapping a row writes back to the same source of truth that drives your
NavigationStack path.
What's the UIKit equivalent?
In UIKit you'd typically use a custom container UIViewController that manages
two child controllers — the main content and the drawer — and animates the drawer's
view.frame or view.transform using
UIView.animate(withDuration:usingSpringWithDamping:…). Popular UIKit libraries
like DrawerKit or SideMenu wrap exactly this pattern. The SwiftUI offset +
withAnimation approach is the direct functional equivalent, with far less
boilerplate.
Last reviewed: 2026-05-11 by the Soarias team.