What You Will Build
A Material Design-style floating action button (FAB) that expands into a vertical menu of action buttons when tapped. The plus icon rotates 45 degrees to become an X, and child buttons fan out with staggered spring animations. This is the standard pattern for apps like Google Drive, Evernote, and task managers where the primary action branches into sub-actions.
Why This Pattern Matters
FABs solve the "too many primary actions" problem. Instead of cluttering the toolbar, you nest secondary actions behind a single button that only expands on demand. SwiftUI makes the staggered reveal trivial with delayed animations and offset manipulation.
Key SwiftUI Concepts
- Staggered animation delays using
.animation(.easeOut.delay(Double(index) * 0.1)). - rotationEffect for the plus-to-X icon transition.
- Configurable direction — the menu can expand top, bottom, left, or right.
Step 1: Define the Action Button and Direction
struct FABActionButton {
let icon: String
let action: () -> Void
}
enum FABDirection {
case left, right, top, bottom
var angle: Angle {
switch self {
case .left: return .degrees(-90)
case .right: return .degrees(90)
case .top: return .degrees(0)
case .bottom: return .degrees(180)
}
}
}
Step 2: Build the FAB Menu
The menu uses rotationEffect on the container to point it in the configured direction, and each child button gets a counter-rotation so it stays upright:
struct FABMenu: View {
let direction: FABDirection
let actionButtons: [FABActionButton]
@State var showButtons = false
var body: some View {
ZStack {
// Child action buttons
ZStack {
ForEach(actionButtons.indices, id: \.self) { index in
let button = actionButtons[index]
Button(action: {
button.action()
withAnimation { showButtons = false }
}) {
Image(systemName: button.icon)
.frame(width: 50, height: 50)
.background(.gray.opacity(0.3), in: .circle)
.rotationEffect(-direction.angle, anchor: .center)
}
.tint(.primary)
.padding(.bottom, 8)
.scaleEffect(showButtons ? 1 : 0)
.opacity(showButtons ? 1 : 0)
.offset(y: showButtons ? Double(index + 1) * -65 : 60)
.animation(
.easeOut.delay(Double(index) * 0.1),
value: showButtons
)
}
}
.rotationEffect(direction.angle, anchor: .center)
// Main FAB button
Image(systemName: "plus")
.foregroundStyle(.black)
.font(.system(size: 30))
.padding()
.background(.white, in: .circle)
.rotationEffect(.degrees(showButtons ? -45 : 0))
.shadow(color: .black.opacity(0.1), radius: 15)
.onTapGesture {
withAnimation { showButtons.toggle() }
}
}
}
}
Step 3: Place It in the Bottom Corner
struct FloatingActionButtonDemo: View {
var body: some View {
ZStack(alignment: .bottomTrailing) {
Color.clear // your main content here
FABMenu(
direction: .top,
actionButtons: [
FABActionButton(icon: "globe", action: {}),
FABActionButton(icon: "paintpalette.fill", action: {}),
FABActionButton(icon: "doc.fill", action: {})
]
)
.padding(30)
}
}
}
How the Direction System Works
The entire child button stack is built to expand upward (negative Y offset). To expand in a different direction, the container is rotated by the direction angle. Each child button applies a counter-rotation so the icons remain right-side-up regardless of the expansion direction. This single rotation trick replaces the need for four different offset calculations.
Tips and Pitfalls
- Dismiss on outside tap: In production, add a full-screen transparent overlay behind the FAB that closes the menu when tapped.
- Stagger order: The delay
Double(index) * 0.1creates a cascade. Reverse it for a closing cascade effect. - Shadow on the main button elevates it visually above the child buttons, which is essential for Material Design hierarchy.
- Accessibility: The rotated plus icon at -45 degrees visually becomes an X, signaling "close" without additional icons.
- Z-order: The main FAB renders last in the ZStack, so it visually sits on top of the child buttons.
iOS Version: iOS 15+