```html SwiftUI: How to Build a Floating Action Button (iOS 17+, 2026)

How to Build a Floating Action Button in SwiftUI

iOS 17+ Xcode 16+ Beginner APIs: ZStack / overlay Updated: May 11, 2026
TL;DR

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

  1. .overlay(alignment: .bottomTrailing) — The primary FAB overlay pins a VStack to the bottom-trailing corner of the NavigationStack. Because it's an overlay rather than a ZStack sibling, the safe-area insets of the list remain unaffected and the FAB floats above the content without pushing layout.
  2. FloatingActionButton component — Wraps Button with a fixed 56×56 frame, a Circle background shape via the in: shorthand, and a coloured drop shadow. .contentShape(Circle()) ensures the tap target is circular, preventing accidental taps at the corners of the bounding frame.
  3. Expandable mini-actions with .transition — The two secondary FABs are conditionally shown based on isMenuOpen. Wrapping the toggle in withAnimation(.spring) causes SwiftUI to animate the .move + .opacity transitions automatically — no explicit Animation modifier needed on each view.
  4. Icon rotation cue — Passing isMenuOpen ? "xmark" : "plus" as systemImage gives the primary FAB a clear open/close affordance. SwiftUI cross-fades the SF Symbol swap inside the spring animation, so no extra .animation modifier is required.
  5. Scrim layer — A second .overlay without 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

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.

```