Bottom Drawer
A draggable bottom drawer that snaps to detents, built as a custom overlay.

- Navigation
- iOS 18.0+
- intermediate
- Origin
- Original work
- Found by
- bottom sheet · drawer · detents · drag · snap · overlay · gesture · hoja inferior · cajón · panel inferior · arrastrar · anclaje · superposición · gesto
Description
Section titled “Description”A drawer that rests on detents, given as fractions of the available height.
Place it above your content in a ZStack. The whole drawer is draggable, so keep scrolling content out of it. VoiceOver adjusts the handle. With Reduce Motion on, the drawer snaps without a spring.
struct BottomDrawerDemo: View { @State private var detent: CGFloat = 0.16
private let places = [ ("Harbor Coffee", "cup.and.saucer.fill", Color.brown), ("City Library", "books.vertical.fill", Color.indigo), ("Riverside Park", "leaf.fill", Color.green), ("Old Town Bakery", "birthday.cake.fill", Color.pink), ]
var body: some View { ZStack { LinearGradient(colors: [.teal, .cyan, .mint], startPoint: .topLeading, endPoint: .bottomTrailing) .ignoresSafeArea()
Image(systemName: "mappin.and.ellipse") .font(.largeTitle) .imageScale(.large) .foregroundStyle(.white) .shadow(radius: 6) .offset(y: -180) .accessibilityHidden(true)
BottomDrawer(detent: $detent) { VStack(alignment: .leading, spacing: 18) { Text("Nearby") .font(.title2.bold())
ForEach(places, id: \.0) { name, symbol, color in Label { Text(name).font(.headline) } icon: { // One width for every symbol, so the names line up. Image(systemName: symbol).foregroundStyle(color.gradient).frame(width: 28) } } } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 24) } } }}API reference
Section titled “API reference”| Parameter | Type | Default |
|---|---|---|
detent | Binding<CGFloat> | Required |
detents | [CGFloat] | [0.16, 0.5, 0.88] |
content | () -> Content | Required |
Copy this code into your project, or download the file.
// SPDX-License-Identifier: MIT// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
enum BottomDrawerSnapping { /// The stop closest to where a drag would come to rest. A drag upwards has a negative `predictedDrag`. static func stop(from current: CGFloat, predictedDrag: CGFloat, stops: [CGFloat]) -> CGFloat { let projected = current - predictedDrag return stops.min { abs($0 - projected) < abs($1 - projected) } ?? current }
/// The stop `step` places away from `current`, held at the ends. With no stops it keeps `current`. static func stop(from current: CGFloat, step: Int, stops: [CGFloat]) -> CGFloat { let sorted = stops.sorted() guard !sorted.isEmpty else { return current } let index = sorted.firstIndex(of: current) ?? 0 return sorted[min(max(index + step, 0), sorted.count - 1)] }
/// Keeps the drawer between its lowest and highest stop while it is dragged. static func clamped(_ value: CGFloat, stops: [CGFloat]) -> CGFloat { guard let lowest = stops.min(), let highest = stops.max() else { return value } return min(max(value, lowest), highest) }}
/// A drawer that rests on detents, given as fractions of the available height.////// Place it above your content in a `ZStack`. The whole drawer is draggable, so keep scrolling/// content out of it. VoiceOver adjusts the handle. With Reduce Motion on, the drawer snaps without a spring.struct BottomDrawer<Content: View>: View { @Binding var detent: CGFloat var detents: [CGFloat] = [0.16, 0.5, 0.88] @ViewBuilder var content: Content
@State private var dragTranslation: CGFloat = 0 @GestureState private var isDragging = false @Environment(\.accessibilityReduceMotion) private var reduceMotion
private var animation: Animation? { reduceMotion ? nil : .spring(duration: 0.45, bounce: 0.2) }
var body: some View { GeometryReader { proxy in let total = max(proxy.size.height, 1) let visible = BottomDrawerSnapping.clamped(detent - dragTranslation / total, stops: detents)
VStack(spacing: 0) { handle content Spacer(minLength: 0) } .frame(maxWidth: .infinity) .frame(height: (detents.max() ?? 1) * total, alignment: .top) .background(.regularMaterial, in: UnevenRoundedRectangle(topLeadingRadius: 28, topTrailingRadius: 28)) .shadow(color: .black.opacity(0.18), radius: 18, y: -4) .contentShape(.rect) .offset(y: total * (1 - visible)) .gesture(dragGesture(total: total)) } .ignoresSafeArea(edges: .bottom) .onChange(of: isDragging) { _, dragging in if !dragging { withAnimation(animation) { dragTranslation = 0 } } } }
private var handle: some View { Capsule() .fill(.secondary) .frame(width: 40, height: 5) .padding(.vertical, 12) .frame(maxWidth: .infinity) .contentShape(.rect) .accessibilityElement() .accessibilityLabel("Drawer") .accessibilityValue("Position \(sortedIndex + 1) of \(detents.count)") .accessibilityAdjustableAction { direction in switch direction { case .increment: moveDetent(by: 1) case .decrement: moveDetent(by: -1) @unknown default: break } } }
private var sortedDetents: [CGFloat] { detents.sorted() } private var sortedIndex: Int { sortedDetents.firstIndex(of: detent) ?? 0 }
private func moveDetent(by step: Int) { withAnimation(animation) { detent = BottomDrawerSnapping.stop(from: detent, step: step, stops: detents) } }
private func dragGesture(total: CGFloat) -> some Gesture { DragGesture() .updating($isDragging) { _, state, _ in state = true } .onChanged { dragTranslation = $0.translation.height } .onEnded { value in let target = BottomDrawerSnapping.stop( from: detent, predictedDrag: value.predictedEndTranslation.height / total, stops: detents ) withAnimation(animation) { detent = target dragTranslation = 0 } } }}
// MARK: - Demo
struct BottomDrawerDemo: View { @State private var detent: CGFloat = 0.16
private let places = [ ("Harbor Coffee", "cup.and.saucer.fill", Color.brown), ("City Library", "books.vertical.fill", Color.indigo), ("Riverside Park", "leaf.fill", Color.green), ("Old Town Bakery", "birthday.cake.fill", Color.pink), ]
var body: some View { ZStack { LinearGradient(colors: [.teal, .cyan, .mint], startPoint: .topLeading, endPoint: .bottomTrailing) .ignoresSafeArea()
Image(systemName: "mappin.and.ellipse") .font(.largeTitle) .imageScale(.large) .foregroundStyle(.white) .shadow(radius: 6) .offset(y: -180) .accessibilityHidden(true)
BottomDrawer(detent: $detent) { VStack(alignment: .leading, spacing: 18) { Text("Nearby") .font(.title2.bold())
ForEach(places, id: \.0) { name, symbol, color in Label { Text(name).font(.headline) } icon: { // One width for every symbol, so the names line up. Image(systemName: symbol).foregroundStyle(color.gradient).frame(width: 28) } } } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 24) } } }}
#Preview { BottomDrawerDemo()}