How to Build a Floating Action Button in SwiftUI
Layer a circular Button over your content using .overlay(alignment: .bottomTrailing) — or a ZStack — then nudge it into the corner with .padding(). That's the entire pattern.
struct ContentView: View {
var body: some View {
NavigationStack {
List { /* rows */ }
}
.overlay(alignment: .bottomTrailing) {
Button {
print("FAB tapped")
} label: {
Image(systemName: "plus")
.font(.title2.bold())
.foregroundStyle(.white)
.frame(width: 56, height: 56)
.background(.blue, in: Circle())
.shadow(radius: 4, y: 2)
}
.padding(24)
.accessibilityLabel("Add item")
}
}
}
Full implementation
The example below builds a notes list with a FAB that animates its icon between a plus and an X when an expandable action menu is open. The FAB itself is a reusable FloatingActionButton view that accepts a label and action closure, keeping the call site clean. State lives in the parent view so SwiftUI can redraw only the affected subtree.
import SwiftUI
// MARK: - Reusable FAB component
struct FloatingActionButton: View {
let systemImage: String
let label: String
let color: Color
let action: () -> Void
var body: some View {
Button(action: action) {
Image(systemName: systemImage)
.font(.title2.bold())
.foregroundStyle(.white)
.frame(width: 56, height: 56)
.background(color, in: Circle())
.shadow(color: color.opacity(0.45), radius: 6, y: 3)
.contentShape(Circle())
}
.accessibilityLabel(label)
.buttonStyle(.plain)
}
}
// MARK: - Sample note model
struct Note: Identifiable {
let id = UUID()
var text: String
}
// MARK: - Main view
struct NotesListView: View {
@State private var notes: [Note] = [
Note(text: "Buy oat milk"),
Note(text: "Review PR #42"),
Note(text: "Ship Soarias update"),
]
@State private var isMenuOpen = false
@State private var showingAddSheet = false
@State private var showingCamera = false
var body: some View {
NavigationStack {
List {
ForEach(notes) { note in
Text(note.text)
}
.onDelete { notes.remove(atOffsets: $0) }
}
.navigationTitle("Notes")
.toolbar { EditButton() }
}
// Dim background when menu is open
.overlay {
if isMenuOpen {
Color.black.opacity(0.25)
.ignoresSafeArea()
.onTapGesture { withAnimation(.spring) { isMenuOpen = false } }
}
}
// FAB + expandable mini-actions
.overlay(alignment: .bottomTrailing) {
VStack(alignment: .trailing, spacing: 16) {
if isMenuOpen {
FloatingActionButton(
systemImage: "camera.fill",
label: "Scan note",
color: .purple
) {
showingCamera = true
withAnimation(.spring) { isMenuOpen = false }
}
.transition(.move(edge: .bottom).combined(with: .opacity))
FloatingActionButton(
systemImage: "square.and.pencil",
label: "New note",
color: .indigo
) {
showingAddSheet = true
withAnimation(.spring) { isMenuOpen = false }
}
.transition(.move(edge: .bottom).combined(with: .opacity))
}
// Primary FAB — rotates into X when open
FloatingActionButton(
systemImage: isMenuOpen ? "xmark" : "plus",
label: isMenuOpen ? "Close menu" : "Open actions",
color: .blue
) {
withAnimation(.spring(response: 0.35, dampingFraction: 0.7)) {
isMenuOpen.toggle()
}
}
}
.padding(24)
}
.sheet(isPresented: $showingAddSheet) {
AddNoteSheet { text in
notes.append(Note(text: text))
}
}
}
}
// MARK: - Add note sheet
struct AddNoteSheet: View {
@Environment(\.dismiss) private var dismiss
@State private var text = ""
let onAdd: (String) -> Void
var body: some View {
NavigationStack {
Form {
TextField("Note text", text: $text, axis: .vertical)
.lineLimit(3...)
}
.navigationTitle("New Note")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Add") {
onAdd(text)
dismiss()
}
.disabled(text.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
}
}
}
// MARK: - Preview
#Preview {
NotesListView()
}
How it works
-
.overlay(alignment: .bottomTrailing) — The primary FAB overlay pins a
VStackto the bottom-trailing corner of theNavigationStack. Because it's an overlay rather than aZStacksibling, the safe-area insets of the list remain unaffected and the FAB floats above the content without pushing layout. -
FloatingActionButton component — Wraps
Buttonwith a fixed 56×56 frame, aCirclebackground shape via thein:shorthand, and a coloured drop shadow..contentShape(Circle())ensures the tap target is circular, preventing accidental taps at the corners of the bounding frame. -
Expandable mini-actions with
.transition— The two secondary FABs are conditionally shown based onisMenuOpen. Wrapping the toggle inwithAnimation(.spring)causes SwiftUI to animate the.move + .opacitytransitions automatically — no explicitAnimationmodifier needed on each view. -
Icon rotation cue — Passing
isMenuOpen ? "xmark" : "plus"assystemImagegives the primary FAB a clear open/close affordance. SwiftUI cross-fades the SF Symbol swap inside the spring animation, so no extra.animationmodifier is required. -
Scrim layer — A second
.overlaywithout an alignment adds a semi-transparent dimming view behind the FAB menu when open. Tapping the scrim calls the same toggle closure so the gesture feels natural and dismisses without requiring a dedicated close button.
Variants
FAB with a badge counter
struct BadgedFAB: View {
let count: Int
let action: () -> Void
var body: some View {
Button(action: action) {
Image(systemName: "tray.fill")
.font(.title2.bold())
.foregroundStyle(.white)
.frame(width: 56, height: 56)
.background(.orange, in: Circle())
.shadow(radius: 4, y: 2)
// Native badge overlay — iOS 17+
.overlay(alignment: .topTrailing) {
if count > 0 {
Text("\(count)")
.font(.caption2.bold())
.foregroundStyle(.white)
.padding(.horizontal, 5)
.background(.red, in: Capsule())
.offset(x: 6, y: -6)
}
}
}
.accessibilityLabel("Inbox, \(count) unread")
.buttonStyle(.plain)
}
}
#Preview {
BadgedFAB(count: 3) { }
.padding()
}
FAB anchored with ZStack instead of overlay
If you need the FAB inside a ScrollView rather than floating over an entire screen, use a ZStack with Spacer()-based alignment instead:
ZStack(alignment: .bottomTrailing) {
ScrollView {
LazyVStack { /* content */ }
.padding(.bottom, 88) // room for the FAB
}
FloatingActionButton(systemImage: "plus", label: "Add", color: .blue) {
// action
}
.padding(24)
.ignoresSafeArea(edges: .bottom)
}
Common pitfalls
-
Home indicator overlap on iPhone 15/16. A plain
.padding(24)is not enough on notched/dynamic-island devices if you ignore the safe area. Always let the system safe area do its work — avoid.ignoresSafeArea()on the FAB unless you're accounting for it manually withsafeAreaInsets. -
TabView conflicts. When your view is inside a
TabView, the FAB's.overlayclips at the tab-bar boundary. Attach the.overlayto theTabViewitself (or use a custom tab bar) so the FAB floats above the tabs. -
Accessibility frame size. WCAG recommends a minimum tap target of 44×44 pt. A 56×56 FAB easily meets this, but if you reduce the size for a "mini FAB", ensure you pad with
.contentShapeto keep the accessible hit area large enough, or VoiceOver will struggle to activate it.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement a floating action button in SwiftUI for iOS 17+. Use ZStack/overlay with alignment: .bottomTrailing. Make it accessible (VoiceOver labels, 56pt minimum target). Support an expandable mini-action menu with spring animation. Add a #Preview with realistic sample data.
In Soarias' Build phase, drop this prompt into the Code step after your screen mockup is approved — Claude Code will scaffold the FloatingActionButton component and wire it to your existing list view in one pass.
Related
FAQ
Does this work on iOS 16?
The .overlay(alignment:) modifier and Circle() background shorthand both work on iOS 16. However, the #Preview macro requires Xcode 15+ and iOS 17 simulator targets. If you need iOS 16 support, swap #Preview for the legacy PreviewProvider protocol — everything else is backwards compatible.
How do I hide the FAB when the keyboard is shown?
Observe UIResponder.keyboardWillShowNotification via .onReceive and set a @State var keyboardVisible = true flag. Then conditionally render the FAB with if !keyboardVisible inside the overlay. In iOS 17 you can also use the new .safeAreaPadding(.bottom, keyboardHeight) modifier to automatically shift the FAB above the keyboard without hiding it.
What's the UIKit equivalent?
In UIKit you'd add a UIButton with a circular layer mask as a subview of the view controller's view (not the table/collection view), then manually pin it to the bottom-trailing corner with Auto Layout constraints — trailingAnchor, bottomAnchor, fixed width/height anchors, and a cornerRadius equal to half the button width. SwiftUI's .overlay approach is considerably more concise and automatically respects safe areas.
Last reviewed: 2026-05-11 by the Soarias team.