swiftui · tutorial · animation · components

Dismissible Banner with Queueing

Published August 3, 2026 · 5 min read · intermediate

Goal

By the end of this tutorial, you will build a reusable SwiftUI banner system that can queue messages, display one banner at a time, and dismiss the current banner with a smooth transition.

State it explicitly:

“Tap buttons to queue banners, show one at a time, and let each message dismiss cleanly.”

Requirements

  • Basic SwiftUI knowledge
  • Xcode 15+
  • iOS 17+

The use case

Dismissible banners show up in real apps whenever you need lightweight feedback: saved successfully, copied to clipboard, failed to load, or an action is waiting in the queue. If the user taps twice quickly, you usually want the second message to wait instead of replacing the first one immediately.

That is the problem queueing solves.

Step 1 - The simplest working version

Start with one banner and one piece of state.

import SwiftUI

struct BannerMessage: Identifiable, Equatable {
    let id = UUID()
    let title: String
}

struct BannerDemoScreen: View {
    @State private var currentBanner: BannerMessage?

    var body: some View {
        VStack(spacing: 20) {
            Button("Show banner") {
                currentBanner = BannerMessage(title: "Saved")
            }
        }
        .overlay(alignment: .top) {
            if let currentBanner {
                BannerView(message: currentBanner) {
                    self.currentBanner = nil
                }
                .padding()
            }
        }
    }
}

This already gives you the right behavior for one message.

Quick verification: Tap the button once and confirm the banner appears and dismisses.

Step 2 - Make it reusable

Now add a queue so multiple messages can stack up safely.

struct BannerDemoScreen: View {
    @State private var queue: [BannerMessage] = []

    private var currentBanner: BannerMessage? { queue.first }

    var body: some View {
        VStack(spacing: 20) {
            Button("Show banner") {
                queue.append(BannerMessage(title: "Saved"))
            }
            Button("Show error") {
                queue.append(BannerMessage(title: "Network error"))
            }
        }
        .overlay(alignment: .top) {
            if let currentBanner {
                BannerView(message: currentBanner) {
                    queue.removeFirst()
                }
                .padding()
                .transition(.move(edge: .top).combined(with: .opacity))
            }
        }
    }
}

What changed: The screen now stores a queue instead of a single value. That means repeated actions do not overwrite each other. The banner remains reusable because it only knows how to render and dismiss one message.

Quick verification: Tap both buttons quickly and confirm the second message waits until the first one is dismissed.

Step 3 - SwiftUI-specific refinement

Add timed dismissal so the queue can advance automatically.

struct BannerDemoScreen: View {
    @State private var queue: [BannerMessage] = []
    @State private var activeID: BannerMessage.ID?

    private var currentBanner: BannerMessage? { queue.first }

    var body: some View {
        VStack(spacing: 20) {
            Button("Show banner") {
                queue.append(BannerMessage(title: "Saved"))
            }
            Button("Show error") {
                queue.append(BannerMessage(title: "Network error"))
            }
        }
        .task(id: currentBanner?.id) {
            guard currentBanner != nil else { return }
            try? await Task.sleep(for: .seconds(2))
            if currentBanner?.id == queue.first?.id {
                queue.removeFirst()
            }
        }
        .overlay(alignment: .top) {
            if let currentBanner {
                BannerView(message: currentBanner) {
                    queue.removeFirst()
                }
                .padding()
                .transition(.move(edge: .top).combined(with: .opacity))
            }
        }
    }
}

The important part is task(id:). It ties the auto-dismiss work to the banner currently on screen, so a new banner cancels the old timing and starts a fresh one. That is what makes queueing feel stable instead of chaotic. Without the id, a banner can linger too long or dismiss at the wrong moment when the screen updates for unrelated reasons.

Quick verification: Tap the buttons in quick succession and confirm each banner gets its own display time.

Result

You now have a banner pattern that is:

  • reusable
  • queue-aware
  • easy to dismiss manually
  • safe for repeated messages

That makes it useful for small status messages and larger flows alike.

Queueing also helps when the app produces messages faster than the user can read them. Instead of overwriting a success message with an error message, the user gets both in order. That is a small detail, but it makes the UI feel respectful. It also makes debugging easier because each banner is a distinct event, not a transient replacement.

Common SwiftUI pitfalls

  • Mistake: Replacing the current banner immediately
    Why it happens: the queue is collapsed into one optional value
    How to fix it: store a queue and show only the first item

  • Mistake: Putting the dismissal timer in onAppear
    Why it happens: the first working version seems simple
    How to fix it: use task(id:) so the timer follows the visible banner

  • Mistake: Letting the banner own the queue
    Why it happens: component extraction happens too early
    How to fix it: keep queue ownership at the screen level and keep the banner dumb

  • Mistake: Forgetting that animation and queueing are separate concerns Why it happens: the transition is added before the message flow is designed How to fix it: decide the queue behavior first, then apply the transition once the state flow is stable

When NOT to use this

If the feedback is blocking or requires a response, a banner is the wrong pattern. Use a dialog or a dedicated screen instead.

Takeaway

If you remember one thing: Queue the messages at the screen boundary, not inside the banner itself.

Next steps

If you want to keep this pattern flexible, these are the best follow-ups: