Undo Snackbar
A bottom bar with an Undo button and a shrinking timer line.

- Feedback
- iOS 18.0+
- beginner
- Origin
- Original work
- Found by
- snackbar · undo · timer · countdown · bottom bar · toast · feedback · snackbar · deshacer · temporizador · cuenta atrás · barra inferior · aviso · archivar
Description
Section titled “Description”Shows an UndoSnackbar at the bottom for duration seconds.
A bottom bar with an Undo button and a line that shrinks until the bar expires.
With Reduce Motion on, the line stays still and the bar simply fades away on time.
struct UndoSnackbarDemo: View { private static let allMessages = ["Team lunch on Friday", "Your flight is confirmed", "Invoice for October"]
@State private var messages = allMessages @State private var lastArchived: String? @State private var showsSnackbar = false
var body: some View { ZStack { LinearGradient(colors: [.blue.opacity(0.25), .indigo.opacity(0.2)], startPoint: .top, endPoint: .bottom) .ignoresSafeArea()
VStack(spacing: 14) { HStack { Text("Inbox") .font(.largeTitle.bold())
Spacer()
Button("Archive", systemImage: "archivebox.fill", action: archive) .font(.headline) .buttonStyle(.borderedProminent) .disabled(showsSnackbar) } .padding(.bottom, 10)
ForEach(messages, id: \.self) { message in Label(message, systemImage: "envelope.fill") .font(.headline) .padding(18) .frame(maxWidth: .infinity, alignment: .leading) .background(.regularMaterial, in: .rect(cornerRadius: 16)) .transition(.opacity) }
Spacer() } .padding(24) .animation(.smooth, value: messages) } .undoSnackbar(isPresented: $showsSnackbar, message: "Conversation archived", duration: 3) { if let lastArchived { messages.insert(lastArchived, at: 0) } } }
private func archive() { if messages.isEmpty { messages = Self.allMessages } lastArchived = messages.removeFirst() showsSnackbar = true }}API reference
Section titled “API reference”undoSnackbar(isPresented:message:duration:onUndo:)
| Parameter | Type | Default |
|---|---|---|
isPresented | Binding<Bool> | Required |
message | LocalizedStringResource | Required |
duration | Double | 5 |
onUndo | @escaping () -> Void | Required |
UndoSnackbar
| Parameter | Type | Default |
|---|---|---|
message | LocalizedStringResource | Required |
duration | Double | 5 |
tint | Color | .yellow |
onUndo | () -> Void | Required |
onExpire | () -> Void | 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
/// A bottom bar with an Undo button and a line that shrinks until the bar expires.////// With Reduce Motion on, the line stays still and the bar simply fades away on time.struct UndoSnackbar: View { let message: LocalizedStringResource var duration: Double = 5 var tint: Color = .yellow let onUndo: () -> Void let onExpire: () -> Void
@State private var remaining = 1.0 @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.accessibilityVoiceOverEnabled) private var voiceOverEnabled
var body: some View { HStack(spacing: 12) { Text(message) .font(.subheadline.weight(.medium)) .frame(maxWidth: .infinity, alignment: .leading)
Button(action: onUndo) { Text("Undo") .font(.subheadline.bold()) .foregroundStyle(tint) .frame(minWidth: 44, minHeight: 44) .contentShape(.rect) } .buttonStyle(.plain) } .foregroundStyle(.white) .padding(.horizontal, 18) .padding(.vertical, 6) .background(.black.opacity(0.85)) .overlay(alignment: .bottom) { Rectangle() .fill(tint) .frame(height: 3) .scaleEffect(x: remaining, anchor: .leading) .accessibilityHidden(true) } .clipShape(.rect(cornerRadius: 16)) .shadow(color: .black.opacity(0.2), radius: 14, y: 6) .task { await runTimer() } }
private func runTimer() async { AccessibilityNotification.Announcement(String(localized: "\(message). Undo available.")).post() let seconds = voiceOverEnabled ? duration * 2 : duration if !reduceMotion { withAnimation(.linear(duration: seconds)) { remaining = 0 } } do { try await Task.sleep(for: .seconds(seconds)) } catch { return } onExpire() }}
private struct UndoSnackbarModifier: ViewModifier { @Binding var isPresented: Bool let message: LocalizedStringResource let duration: Double let onUndo: () -> Void
@Environment(\.accessibilityReduceMotion) private var reduceMotion
func body(content: Content) -> some View { content .overlay(alignment: .bottom) { if isPresented { UndoSnackbar(message: message, duration: duration) { isPresented = false onUndo() } onExpire: { isPresented = false } .padding(.horizontal, 16) .padding(.bottom, 12) .transition(reduceMotion ? .opacity : .move(edge: .bottom).combined(with: .opacity)) } } .animation(reduceMotion ? .easeInOut(duration: 0.2) : .spring(duration: 0.4, bounce: 0.2), value: isPresented) }}
extension View { /// Shows an `UndoSnackbar` at the bottom for `duration` seconds. func undoSnackbar( isPresented: Binding<Bool>, message: LocalizedStringResource, duration: Double = 5, onUndo: @escaping () -> Void ) -> some View { modifier(UndoSnackbarModifier(isPresented: isPresented, message: message, duration: duration, onUndo: onUndo)) }}
// MARK: - Demo
struct UndoSnackbarDemo: View { private static let allMessages = ["Team lunch on Friday", "Your flight is confirmed", "Invoice for October"]
@State private var messages = allMessages @State private var lastArchived: String? @State private var showsSnackbar = false
var body: some View { ZStack { LinearGradient(colors: [.blue.opacity(0.25), .indigo.opacity(0.2)], startPoint: .top, endPoint: .bottom) .ignoresSafeArea()
VStack(spacing: 14) { HStack { Text("Inbox") .font(.largeTitle.bold())
Spacer()
Button("Archive", systemImage: "archivebox.fill", action: archive) .font(.headline) .buttonStyle(.borderedProminent) .disabled(showsSnackbar) } .padding(.bottom, 10)
ForEach(messages, id: \.self) { message in Label(message, systemImage: "envelope.fill") .font(.headline) .padding(18) .frame(maxWidth: .infinity, alignment: .leading) .background(.regularMaterial, in: .rect(cornerRadius: 16)) .transition(.opacity) }
Spacer() } .padding(24) .animation(.smooth, value: messages) } .undoSnackbar(isPresented: $showsSnackbar, message: "Conversation archived", duration: 3) { if let lastArchived { messages.insert(lastArchived, at: 0) } } }
private func archive() { if messages.isEmpty { messages = Self.allMessages } lastArchived = messages.removeFirst() showsSnackbar = true }}
#Preview { UndoSnackbarDemo()}