Skip to content

Undo Snackbar

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

Undo Snackbar, playing
Download .swift
Origin
Original work
Found by
snackbar · undo · timer · countdown · bottom bar · toast · feedback · snackbar · deshacer · temporizador · cuenta atrás · barra inferior · aviso · archivar

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
}
}

undoSnackbar(isPresented:message:duration:onUndo:)

ParameterTypeDefault
isPresentedBinding<Bool>Required
messageLocalizedStringResourceRequired
durationDouble5
onUndo@escaping () -> VoidRequired

UndoSnackbar

ParameterTypeDefault
messageLocalizedStringResourceRequired
durationDouble5
tintColor.yellow
onUndo() -> VoidRequired
onExpire() -> VoidRequired

Copy this code into your project, or download the file.

UndoSnackbar.swift
// 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()
}