```html SwiftUI: How to Implement Drawer Navigation (iOS 17+, 2026)

How to implement drawer navigation in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: offset / Animation Updated: May 11, 2026
TL;DR

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

  1. 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.
  2. resolvedOffset merges state + live drag. Instead of two separate .offset calls, a computed property combines the base position (isOpen ? 0 : -drawerWidth) with the in-flight dragOffset. The min(0, …) clamp prevents the drawer from over-extending to the right.
  3. Dim opacity tracks progress linearly. progress = (resolvedOffset + drawerWidth) / drawerWidth maps 0 → 1 as the drawer travels, so the backdrop fades in sync rather than snapping on at the end of the animation.
  4. Spring animation for snap. .spring(response: 0.35, dampingFraction: 0.8) on both the withAnimation call and the .animation(_:value:) modifier ensures the drawer decelerates naturally without overshooting. Adjusting dampingFraction lower (e.g. 0.6) adds a subtle bounce.
  5. Edge-swipe gesture to open. The outer DragGesture on DrawerLayout guards with value.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

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.

```